Bug 1885565 - Part 1: Add mozac_ic_avatar_circle_24 to ui-icons r=android-reviewers...
[gecko.git] / toolkit / components / resistfingerprinting / RFPHelper.sys.mjs
blobbba23df683263508cb00e5d41ca2bcf978ee6a7d
1 // -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
4  * You can obtain one at http://mozilla.org/MPL/2.0/. */
6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
8 const kPrefResistFingerprinting = "privacy.resistFingerprinting";
9 const kPrefSpoofEnglish = "privacy.spoof_english";
10 const kTopicHttpOnModifyRequest = "http-on-modify-request";
12 const kPrefLetterboxing = "privacy.resistFingerprinting.letterboxing";
13 const kPrefLetterboxingDimensions =
14   "privacy.resistFingerprinting.letterboxing.dimensions";
15 const kPrefLetterboxingTesting =
16   "privacy.resistFingerprinting.letterboxing.testing";
17 const kTopicDOMWindowOpened = "domwindowopened";
19 var logConsole;
20 function log(msg) {
21   if (!logConsole) {
22     logConsole = console.createInstance({
23       prefix: "RFPHelper",
24       maxLogLevelPref: "privacy.resistFingerprinting.jsmloglevel",
25     });
26   }
28   logConsole.log(msg);
31 class _RFPHelper {
32   // ============================================================================
33   // Shared Setup
34   // ============================================================================
35   constructor() {
36     this._initialized = false;
37   }
39   init() {
40     if (this._initialized) {
41       return;
42     }
43     this._initialized = true;
45     // Add unconditional observers
46     Services.prefs.addObserver(kPrefResistFingerprinting, this);
47     Services.prefs.addObserver(kPrefLetterboxing, this);
48     XPCOMUtils.defineLazyPreferenceGetter(
49       this,
50       "_letterboxingDimensions",
51       kPrefLetterboxingDimensions,
52       "",
53       null,
54       this._parseLetterboxingDimensions
55     );
56     XPCOMUtils.defineLazyPreferenceGetter(
57       this,
58       "_isLetterboxingTesting",
59       kPrefLetterboxingTesting,
60       false
61     );
63     // Add RFP and Letterboxing observers if prefs are enabled
64     this._handleResistFingerprintingChanged();
65     this._handleLetterboxingPrefChanged();
66   }
68   uninit() {
69     if (!this._initialized) {
70       return;
71     }
72     this._initialized = false;
74     // Remove unconditional observers
75     Services.prefs.removeObserver(kPrefResistFingerprinting, this);
76     Services.prefs.removeObserver(kPrefLetterboxing, this);
77     // Remove the RFP observers, swallowing exceptions if they weren't present
78     this._removeRFPObservers();
79   }
81   observe(subject, topic, data) {
82     switch (topic) {
83       case "nsPref:changed":
84         this._handlePrefChanged(data);
85         break;
86       case kTopicHttpOnModifyRequest:
87         this._handleHttpOnModifyRequest(subject, data);
88         break;
89       case kTopicDOMWindowOpened:
90         // We attach to the newly created window by adding tabsProgressListener
91         // and event listener on it. We listen for new tabs being added or
92         // the change of the content principal and apply margins accordingly.
93         this._handleDOMWindowOpened(subject);
94         break;
95       default:
96         break;
97     }
98   }
100   handleEvent(aMessage) {
101     switch (aMessage.type) {
102       case "TabOpen": {
103         let tab = aMessage.target;
104         this._addOrClearContentMargin(tab.linkedBrowser);
105         break;
106       }
107       default:
108         break;
109     }
110   }
112   _handlePrefChanged(data) {
113     switch (data) {
114       case kPrefResistFingerprinting:
115         this._handleResistFingerprintingChanged();
116         break;
117       case kPrefSpoofEnglish:
118         this._handleSpoofEnglishChanged();
119         break;
120       case kPrefLetterboxing:
121         this._handleLetterboxingPrefChanged();
122         break;
123       default:
124         break;
125     }
126   }
128   contentSizeUpdated(win) {
129     this._updateMarginsForTabsInWindow(win);
130   }
132   // ============================================================================
133   // Language Prompt
134   // ============================================================================
135   _addRFPObservers() {
136     Services.prefs.addObserver(kPrefSpoofEnglish, this);
137     if (this._shouldPromptForLanguagePref()) {
138       Services.obs.addObserver(this, kTopicHttpOnModifyRequest);
139     }
140   }
142   _removeRFPObservers() {
143     try {
144       Services.prefs.removeObserver(kPrefSpoofEnglish, this);
145     } catch (e) {
146       // do nothing
147     }
148     try {
149       Services.obs.removeObserver(this, kTopicHttpOnModifyRequest);
150     } catch (e) {
151       // do nothing
152     }
153   }
155   _handleResistFingerprintingChanged() {
156     if (Services.prefs.getBoolPref(kPrefResistFingerprinting)) {
157       this._addRFPObservers();
158     } else {
159       this._removeRFPObservers();
160     }
161   }
163   _handleSpoofEnglishChanged() {
164     switch (Services.prefs.getIntPref(kPrefSpoofEnglish)) {
165       case 0: // will prompt
166       // This should only happen when turning privacy.resistFingerprinting off.
167       // Works like disabling accept-language spoofing.
168       // fall through
169       case 1: // don't spoof
170         // We don't reset intl.accept_languages. Instead, setting
171         // privacy.spoof_english to 1 allows user to change preferred language
172         // settings through Preferences UI.
173         break;
174       case 2: // spoof
175         Services.prefs.setCharPref("intl.accept_languages", "en-US, en");
176         break;
177       default:
178         break;
179     }
180   }
182   _shouldPromptForLanguagePref() {
183     return (
184       Services.locale.appLocaleAsBCP47.substr(0, 2) !== "en" &&
185       Services.prefs.getIntPref(kPrefSpoofEnglish) === 0
186     );
187   }
189   _handleHttpOnModifyRequest(subject) {
190     // If we are loading an HTTP page from content, show the
191     // "request English language web pages?" prompt.
192     let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel);
194     let notificationCallbacks = httpChannel.notificationCallbacks;
195     if (!notificationCallbacks) {
196       return;
197     }
199     let loadContext = notificationCallbacks.getInterface(Ci.nsILoadContext);
200     if (!loadContext || !loadContext.isContent) {
201       return;
202     }
204     if (!subject.URI.schemeIs("http") && !subject.URI.schemeIs("https")) {
205       return;
206     }
207     // The above QI did not throw, the scheme is http[s], and we know the
208     // load context is content, so we must have a true HTTP request from content.
209     // Stop the observer and display the prompt if another window has
210     // not already done so.
211     Services.obs.removeObserver(this, kTopicHttpOnModifyRequest);
213     if (!this._shouldPromptForLanguagePref()) {
214       return;
215     }
217     this._promptForLanguagePreference();
219     // The Accept-Language header for this request was set when the
220     // channel was created. Reset it to match the value that will be
221     // used for future requests.
222     let val = this._getCurrentAcceptLanguageValue(subject.URI);
223     if (val) {
224       httpChannel.setRequestHeader("Accept-Language", val, false);
225     }
226   }
228   _promptForLanguagePreference() {
229     // Display two buttons, both with string titles.
230     const l10n = new Localization(
231       ["toolkit/global/resistFingerPrinting.ftl"],
232       true
233     );
234     const message = l10n.formatValueSync("privacy-spoof-english");
235     const flags = Services.prompt.STD_YES_NO_BUTTONS;
236     const response = Services.prompt.confirmEx(
237       null,
238       "",
239       message,
240       flags,
241       null,
242       null,
243       null,
244       null,
245       { value: false }
246     );
248     // Update preferences to reflect their response and to prevent the prompt
249     // from being displayed again.
250     Services.prefs.setIntPref(kPrefSpoofEnglish, response == 0 ? 2 : 1);
251   }
253   _getCurrentAcceptLanguageValue(uri) {
254     let channel = Services.io.newChannelFromURI(
255       uri,
256       null, // aLoadingNode
257       Services.scriptSecurityManager.getSystemPrincipal(),
258       null, // aTriggeringPrincipal
259       Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
260       Ci.nsIContentPolicy.TYPE_OTHER
261     );
262     let httpChannel;
263     try {
264       httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
265     } catch (e) {
266       return null;
267     }
268     return httpChannel.getRequestHeader("Accept-Language");
269   }
271   // ==============================================================================
272   // Letterboxing
273   // ============================================================================
274   /**
275    * We use the TabsProgressListener to catch the change of the content
276    * principal. We would clear the margins around the content viewport if
277    * it is the system principal.
278    */
279   onLocationChange(aBrowser) {
280     this._addOrClearContentMargin(aBrowser);
281   }
283   _handleLetterboxingPrefChanged() {
284     if (Services.prefs.getBoolPref(kPrefLetterboxing, false)) {
285       Services.ww.registerNotification(this);
286       this._registerActor();
287       this._attachAllWindows();
288     } else {
289       this._unregisterActor();
290       this._detachAllWindows();
291       Services.ww.unregisterNotification(this);
292     }
293   }
295   _registerActor() {
296     ChromeUtils.registerWindowActor("RFPHelper", {
297       parent: {
298         esModuleURI: "resource:///actors/RFPHelperParent.sys.mjs",
299       },
300       child: {
301         esModuleURI: "resource:///actors/RFPHelperChild.sys.mjs",
302         events: {
303           resize: {},
304         },
305       },
306       allFrames: true,
307     });
308   }
310   _unregisterActor() {
311     ChromeUtils.unregisterWindowActor("RFPHelper");
312   }
314   // The function to parse the dimension set from the pref value. The pref value
315   // should be formated as 'width1xheight1, width2xheight2, ...'. For
316   // example, '100x100, 200x200, 400x200 ...'.
317   _parseLetterboxingDimensions(aPrefValue) {
318     if (!aPrefValue || !aPrefValue.match(/^(?:\d+x\d+,\s*)*(?:\d+x\d+)$/)) {
319       if (aPrefValue) {
320         console.error(
321           `Invalid pref value for ${kPrefLetterboxingDimensions}: ${aPrefValue}`
322         );
323       }
324       return [];
325     }
327     return aPrefValue.split(",").map(item => {
328       let sizes = item.split("x").map(size => parseInt(size, 10));
330       return {
331         width: sizes[0],
332         height: sizes[1],
333       };
334     });
335   }
337   _addOrClearContentMargin(aBrowser) {
338     let tab = aBrowser.getTabBrowser().getTabForBrowser(aBrowser);
340     // We won't do anything for lazy browsers.
341     if (!aBrowser.isConnected) {
342       return;
343     }
345     // We should apply no margin around an empty tab or a tab with system
346     // principal.
347     if (tab.isEmpty || aBrowser.contentPrincipal.isSystemPrincipal) {
348       this._clearContentViewMargin(aBrowser);
349     } else {
350       this._roundContentView(aBrowser);
351     }
352   }
354   /**
355    * Given a width or height, returns the appropriate margin to apply.
356    */
357   steppedRange(aDimension) {
358     let stepping;
359     if (aDimension <= 50) {
360       return 0;
361     } else if (aDimension <= 500) {
362       stepping = 50;
363     } else if (aDimension <= 1600) {
364       stepping = 100;
365     } else {
366       stepping = 200;
367     }
369     return (aDimension % stepping) / 2;
370   }
372   /**
373    * The function will round the given browser by adding margins around the
374    * content viewport.
375    */
376   async _roundContentView(aBrowser) {
377     let logId = Math.random();
378     log("_roundContentView[" + logId + "]");
379     let win = aBrowser.ownerGlobal;
380     let browserContainer = aBrowser
381       .getTabBrowser()
382       .getBrowserContainer(aBrowser);
384     let { contentWidth, contentHeight, containerWidth, containerHeight } =
385       await win.promiseDocumentFlushed(() => {
386         let contentWidth = aBrowser.clientWidth;
387         let contentHeight = aBrowser.clientHeight;
388         let containerWidth = browserContainer.clientWidth;
389         let containerHeight = browserContainer.clientHeight;
391         // If the findbar or devtools are out, we need to subtract their height (plus 1
392         // for the separator) from the container height, because we need to adjust our
393         // letterboxing to account for it; however it is not included in that dimension
394         // (but rather is subtracted from the content height.)
395         let findBar = win.gFindBarInitialized ? win.gFindBar : undefined;
396         let findBarOffset =
397           findBar && !findBar.hidden ? findBar.clientHeight + 1 : 0;
398         let devtools = browserContainer.getElementsByClassName(
399           "devtools-toolbox-bottom-iframe"
400         );
401         let devtoolsOffset = devtools.length ? devtools[0].clientHeight : 0;
403         return {
404           contentWidth,
405           contentHeight,
406           containerWidth,
407           containerHeight: containerHeight - findBarOffset - devtoolsOffset,
408         };
409       });
411     log(
412       "_roundContentView[" +
413         logId +
414         "] contentWidth=" +
415         contentWidth +
416         " contentHeight=" +
417         contentHeight +
418         " containerWidth=" +
419         containerWidth +
420         " containerHeight=" +
421         containerHeight +
422         " "
423     );
425     let calcMargins = (aWidth, aHeight) => {
426       let result;
427       log(
428         "_roundContentView[" +
429           logId +
430           "] calcMargins(" +
431           aWidth +
432           ", " +
433           aHeight +
434           ")"
435       );
436       // If the set is empty, we will round the content with the default
437       // stepping size.
438       if (!this._letterboxingDimensions.length) {
439         result = {
440           width: this.steppedRange(aWidth),
441           height: this.steppedRange(aHeight),
442         };
443         log(
444           "_roundContentView[" +
445             logId +
446             "] calcMargins(" +
447             aWidth +
448             ", " +
449             aHeight +
450             ") = " +
451             result.width +
452             " x " +
453             result.height
454         );
455         return result;
456       }
458       let matchingArea = aWidth * aHeight;
459       let minWaste = Number.MAX_SAFE_INTEGER;
460       let targetDimensions = undefined;
462       // Find the desired dimensions which waste the least content area.
463       for (let dim of this._letterboxingDimensions) {
464         // We don't need to consider the dimensions which cannot fit into the
465         // real content size.
466         if (dim.width > aWidth || dim.height > aHeight) {
467           continue;
468         }
470         let waste = matchingArea - dim.width * dim.height;
472         if (waste >= 0 && waste < minWaste) {
473           targetDimensions = dim;
474           minWaste = waste;
475         }
476       }
478       // If we cannot find any dimensions match to the real content window, this
479       // means the content area is smaller the smallest size in the set. In this
480       // case, we won't apply any margins.
481       if (!targetDimensions) {
482         result = {
483           width: 0,
484           height: 0,
485         };
486       } else {
487         result = {
488           width: (aWidth - targetDimensions.width) / 2,
489           height: (aHeight - targetDimensions.height) / 2,
490         };
491       }
493       log(
494         "_roundContentView[" +
495           logId +
496           "] calcMargins(" +
497           aWidth +
498           ", " +
499           aHeight +
500           ") = " +
501           result.width +
502           " x " +
503           result.height
504       );
505       return result;
506     };
508     // Calculating the margins around the browser element in order to round the
509     // content viewport. We will use a 200x100 stepping if the dimension set
510     // is not given.
511     let margins = calcMargins(containerWidth, containerHeight);
513     // If the size of the content is already quantized, we do nothing.
514     if (aBrowser.style.margin == `${margins.height}px ${margins.width}px`) {
515       log("_roundContentView[" + logId + "] is_rounded == true");
516       if (this._isLetterboxingTesting) {
517         log(
518           "_roundContentView[" +
519             logId +
520             "] is_rounded == true test:letterboxing:update-margin-finish"
521         );
522         Services.obs.notifyObservers(
523           null,
524           "test:letterboxing:update-margin-finish"
525         );
526       }
527       return;
528     }
530     win.requestAnimationFrame(() => {
531       log(
532         "_roundContentView[" +
533           logId +
534           "] setting margins to " +
535           margins.width +
536           " x " +
537           margins.height
538       );
539       // One cannot (easily) control the color of a margin unfortunately.
540       // An initial attempt to use a border instead of a margin resulted
541       // in offset event dispatching; so for now we use a colorless margin.
542       aBrowser.style.margin = `${margins.height}px ${margins.width}px`;
543     });
544   }
546   _clearContentViewMargin(aBrowser) {
547     aBrowser.ownerGlobal.requestAnimationFrame(() => {
548       aBrowser.style.margin = "";
549     });
550   }
552   _updateMarginsForTabsInWindow(aWindow) {
553     let tabBrowser = aWindow.gBrowser;
555     for (let tab of tabBrowser.tabs) {
556       let browser = tab.linkedBrowser;
557       this._addOrClearContentMargin(browser);
558     }
559   }
561   _attachWindow(aWindow) {
562     aWindow.gBrowser.addTabsProgressListener(this);
563     aWindow.addEventListener("TabOpen", this);
565     // Rounding the content viewport.
566     this._updateMarginsForTabsInWindow(aWindow);
567   }
569   _attachAllWindows() {
570     let windowList = Services.wm.getEnumerator("navigator:browser");
572     while (windowList.hasMoreElements()) {
573       let win = windowList.getNext();
575       if (win.closed || !win.gBrowser) {
576         continue;
577       }
579       this._attachWindow(win);
580     }
581   }
583   _detachWindow(aWindow) {
584     let tabBrowser = aWindow.gBrowser;
585     tabBrowser.removeTabsProgressListener(this);
586     aWindow.removeEventListener("TabOpen", this);
588     // Clear all margins and tooltip for all browsers.
589     for (let tab of tabBrowser.tabs) {
590       let browser = tab.linkedBrowser;
591       this._clearContentViewMargin(browser);
592     }
593   }
595   _detachAllWindows() {
596     let windowList = Services.wm.getEnumerator("navigator:browser");
598     while (windowList.hasMoreElements()) {
599       let win = windowList.getNext();
601       if (win.closed || !win.gBrowser) {
602         continue;
603       }
605       this._detachWindow(win);
606     }
607   }
609   _handleDOMWindowOpened(win) {
610     let self = this;
612     win.addEventListener(
613       "load",
614       () => {
615         // We attach to the new window when it has been loaded if the new loaded
616         // window is a browsing window.
617         if (
618           win.document.documentElement.getAttribute("windowtype") !==
619           "navigator:browser"
620         ) {
621           return;
622         }
623         self._attachWindow(win);
624       },
625       { once: true }
626     );
627   }
630 export let RFPHelper = new _RFPHelper();