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, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
setTimeout: "resource://gre/modules/Timer.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
setInterval: "resource://gre/modules/Timer.sys.mjs",
clearInterval: "resource://gre/modules/Timer.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"serviceMode",
"cookiebanners.service.mode",
Ci.nsICookieBannerService.MODE_DISABLED
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"serviceModePBM",
"cookiebanners.service.mode.privateBrowsing",
Ci.nsICookieBannerService.MODE_DISABLED
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"prefDetectOnly",
"cookiebanners.service.detectOnly",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"bannerClickingEnabled",
"cookiebanners.bannerClicking.enabled",
false
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"cleanupTimeoutAfterLoad",
"cookiebanners.bannerClicking.timeoutAfterLoad"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"cleanupTimeoutAfterDOMContentLoaded",
"cookiebanners.bannerClicking.timeoutAfterDOMContentLoaded"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"pollingInterval",
"cookiebanners.bannerClicking.pollingInterval",
500
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"testing",
"cookiebanners.bannerClicking.testing",
false
);
ChromeUtils.defineLazyGetter(lazy, "logConsole", () => {
return console.createInstance({
prefix: "CookieBannerChild",
maxLogLevelPref: "cookiebanners.bannerClicking.logLevel",
});
});
export class CookieBannerChild extends JSWindowActorChild {
// Caches the enabled state to ensure we only compute it once for the lifetime
// of the actor. Particularly the private browsing check can be expensive.
#isEnabledCached = null;
#clickRules;
#abortController = new AbortController();
#timeoutSignalController = new AbortController();
#timeoutTimerID;
#hasActiveObserver = false;
// Indicates whether the page "load" event occurred.
#didLoad = false;
// Indicates whether we should stop running the cookie banner handling
// mechanism because it has been previously executed for the site. So, we can
// cool down the cookie banner handing to improve performance.
#isCooledDownInSession = false;
handleEvent(event) {
if (!this.#isEnabled) {
// Automated tests may still expect the test message to be sent.
this.#maybeSendTestMessage();
return;
}
switch (event.type) {
case "DOMContentLoaded":
this.#onDOMContentLoaded();
break;
case "load":
this.#onLoad();
break;
default:
lazy.logConsole.warn(`Unexpected event ${event.type}.`, event);
}
}
get #isPrivateBrowsing() {
return lazy.PrivateBrowsingUtils.isContentWindowPrivate(this.contentWindow);
}
/**
* Whether the feature is enabled based on pref state.
* @type {boolean} true if feature is enabled, false otherwise.
*/
get #isEnabled() {
if (this.#isEnabledCached != null) {
return this.#isEnabledCached;
}
let checkIsEnabled = () => {
if (!lazy.bannerClickingEnabled) {
return false;
}
if (this.#isPrivateBrowsing) {
return lazy.serviceModePBM != Ci.nsICookieBannerService.MODE_DISABLED;
}
return lazy.serviceMode != Ci.nsICookieBannerService.MODE_DISABLED;
};
this.#isEnabledCached = checkIsEnabled();
return this.#isEnabledCached;
}
/**
* Whether the feature is enabled in detect-only-mode where cookie banner
* detection events are dispatched, but banners aren't handled.
* @type {boolean} true if feature mode is enabled, false otherwise.
*/
get #isDetectOnly() {
// We can't be in detect-only-mode if fully disabled.
if (!this.#isEnabled) {
return false;
}
return lazy.prefDetectOnly;
}
/**
* @returns {boolean} Whether we handled a banner for the current load by
* injecting cookies.
*/
get #hasInjectedCookieForCookieBannerHandling() {
return this.docShell?.currentDocumentChannel?.loadInfo
?.hasInjectedCookieForCookieBannerHandling;
}
/**
* Checks whether we handled a banner for this site by injecting cookies and
* dispatches events.
* @returns {boolean} Whether we handled the banner and dispatched events.
*/
#dispatchEventsForBannerHandledByInjection() {
if (
!this.#hasInjectedCookieForCookieBannerHandling ||
this.#isCooledDownInSession
) {
return false;
}
// Strictly speaking we don't actively detect a banner when we handle it by
// cookie injection. We still dispatch "cookiebannerdetected" in this case
// for consistency.
this.sendAsyncMessage("CookieBanner::DetectedBanner");
this.sendAsyncMessage("CookieBanner::HandledBanner");
return true;
}
/**
* Handler for DOMContentLoaded events which is the entry point for cookie
* banner handling.
*/
async #onDOMContentLoaded() {
lazy.logConsole.debug("onDOMContentLoaded", { didLoad: this.#didLoad });
this.#didLoad = false;
let principal = this.document?.nodePrincipal;
// We only apply banner auto-clicking if the document has a content
// principal.
if (!principal?.isContentPrincipal) {
return;
}
// We don't need to do auto-clicking if it's not a http/https page.
if (!principal.schemeIs("http") && !principal.schemeIs("https")) {
return;
}
lazy.logConsole.debug("Send message to get rule", {
baseDomain: principal.baseDomain,
isTopLevel: this.browsingContext == this.browsingContext?.top,
});
let rules;
try {
let data = await this.sendQuery("CookieBanner::GetClickRules", {});
rules = data.rules;
// Set we are cooling down for this session if the cookie banner handling
// has been executed previously.
this.#isCooledDownInSession = data.hasExecuted;
} catch (e) {
lazy.logConsole.warn("Failed to get click rule from parent.", e);
return;
}
lazy.logConsole.debug("Got rules:", rules);
// We can stop here if we don't have a rule.
if (!rules.length) {
// If the cookie injector has handled the banner and there are no click
// rules we still need to dispatch a "cookiebannerhandled" event.
this.#dispatchEventsForBannerHandledByInjection();
this.#maybeSendTestMessage();
return;
}
this.#clickRules = rules;
let bannerHandled, bannerDetected, matchedRules;
try {
({ bannerHandled, bannerDetected, matchedRules } =
await this.handleCookieBanner());
} catch (e) {
if (DOMException.isInstance(e) && e.name === "AbortError") {
lazy.logConsole.debug("handleCookieBanner() has aborted");
return;
}
throw e;
}
// Send a message to mark that the cookie banner handling has been executed.
this.sendAsyncMessage("CookieBanner::MarkSiteExecuted");
let dispatchedEventsForCookieInjection =
this.#dispatchEventsForBannerHandledByInjection();
// 1. Detected event.
if (bannerDetected) {
lazy.logConsole.info("Detected cookie banner.", {
url: this.document.location.href,
});
// Avoid dispatching a duplicate "cookiebannerdetected" event.
if (!dispatchedEventsForCookieInjection) {
this.sendAsyncMessage("CookieBanner::DetectedBanner");
}
}
// 2. Handled event.
if (bannerHandled) {
lazy.logConsole.info("Handled cookie banner.", {
url: this.document.location.href,
matchedRules,
});
// Avoid dispatching a duplicate "cookiebannerhandled" event.
if (!dispatchedEventsForCookieInjection) {
this.sendAsyncMessage("CookieBanner::HandledBanner");
}
}
this.#maybeSendTestMessage();
}
/**
* Handler for "load" events. Used as a signal to stop observing the DOM for
* cookie banners after a timeout.
*/
#onLoad() {
this.#didLoad = true;
// Exit early if we are not handling banners for this site.
if (!this.#clickRules?.length) {
return;
}
lazy.logConsole.debug("Observed 'load' event", {
href: this.document.location.href,
hasActiveObserver: this.#hasActiveObserver,
observerCleanupTimer: this.#timeoutTimerID,
});
// On load reset the timer for cleanup.
this.#startOrResetCleanupTimer();
}
/**
* We limit how long we observe cookie banner mutations for performance
* reasons. If not present initially on DOMContentLoaded, cookie banners are
* expected to show up during or shortly after page load.
* This method starts a cleanup timeout which duration depends on the current
* load stage (DOMContentLoaded, or load). When called, if a timeout is
* already running, it is cancelled and a new timeout is scheduled.
*/
#startOrResetCleanupTimer() {
// Cancel any already running timeout so we can schedule a new one.
if (this.#timeoutTimerID) {
lazy.logConsole.debug(
"#startOrResetCleanupTimer: Cancelling existing cleanup timeout",
{
didLoad: this.#didLoad,
id: this.#timeoutTimerID,
}
);
lazy.clearTimeout(this.#timeoutTimerID);
this.#timeoutTimerID = null;
}
let durationMS = this.#didLoad
? lazy.cleanupTimeoutAfterLoad
: lazy.cleanupTimeoutAfterDOMContentLoaded;
lazy.logConsole.debug(
"#startOrResetCleanupTimer: Starting cleanup timeout",
{
durationMS,
didLoad: this.#didLoad,
}
);
this.#timeoutTimerID = lazy.setTimeout(() => {
lazy.logConsole.debug(
"#startOrResetCleanupTimer: Cleanup timeout triggered",
{
durationMS,
didLoad: this.#didLoad,
}
);
this.#timeoutTimerID = null;
this.#timeoutSignalController.abort();
}, durationMS);
}
didDestroy() {
lazy.logConsole.debug("didDestroy() called");
// Clean up the observer and polling function.
this.#abortController.abort();
lazy.clearTimeout(this.#timeoutTimerID);
this.#timeoutTimerID = null;
}
/**
* The function to perform the core logic of handing the cookie banner. It
* will detect the banner and click the banner button whenever possible
* according to the given click rules.
* If the service mode pref is set to detect only mode we will only attempt to
* find the cookie banner element and return early.
*
* @returns A promise which resolves when it finishes auto clicking.
*/
async handleCookieBanner() {
lazy.logConsole.debug("handleCookieBanner", this.document.location.href);
// Start timer to clean up detection code (polling and mutation observers).
this.#startOrResetCleanupTimer();
// First, we detect if the banner is shown on the page
let rules = await this.#detectBanner();
if (!rules.length) {
// The banner was never shown.
return { bannerHandled: false, bannerDetected: false };
}
// No rule with valid button to click. This can happen if we're in
// MODE_REJECT and there are only opt-in buttons available.
// This also applies when detect-only mode is enabled. We only want to
// dispatch events matching the current service mode.
if (rules.every(rule => rule.target == null)) {
return { bannerHandled: false, bannerDetected: false };
}
// If the cookie banner prefs only enable detection but not handling we're done here.
if (this.#isDetectOnly) {
return { bannerHandled: false, bannerDetected: true };
}
let successClick = false;
successClick = await this.#clickTarget(rules);
return {
bannerHandled: successClick,
bannerDetected: true,
matchedRules: rules,
};
}
/**
* The helper function to observe the changes on the document with a timeout.
* It will call the check function when it observes mutations on the document
* body. Once the check function returns a truthy value, it will resolve with
* that value. Otherwise, it will resolve with null on timeout.
*
* @param {function} [checkFn] - The check function.
* @returns {Promise} - A promise which resolves with the return value of the
* check function or null if the function times out.
*/
#promiseObserve(checkFn) {
if (this.#hasActiveObserver) {
throw new Error(
"The promiseObserve is called before previous one resolves."
);
}
this.#hasActiveObserver = true;
return new Promise((resolve, reject) => {
if (this.#abortController.signal.aborted) {
reject(this.#abortController.signal.reason);
return;
}
if (this.#timeoutSignalController.signal.aborted) {
resolve(null);
return;
}
let win = this.contentWindow;
// Marks whether a mutation on the site has been observed since we last
// ran checkFn.
let sawMutation = false;
// IDs for interval for checkFn polling.
let pollIntervalId = null;
// Keep track of DOM changes via MutationObserver. We only run query
// selectors again if the DOM updated since our last check.
let observer = new win.MutationObserver(() => {
sawMutation = true;
});
observer.observe(win.document.body, {
attributes: true,
subtree: true,
childList: true,
});
// Start polling checkFn.
let intervalFn = () => {
lazy.logConsole.debug(
"#promiseObserve interval function",
this.document.location.href
);
if (this.#abortController.signal.aborted) {
throw new Error(
"The promiseObserve interval function is still running after banner detection has aborted."
);
}
if (this.#timeoutSignalController.signal.aborted) {
throw new Error(
"The promiseObserve interval function is still running after banner detection has timed out."
);
}
// Nothing changed since last run, skip running checkFn.
if (!sawMutation) {
return;
}
// Reset mutation flag.
sawMutation = false;
// A truthy result means we have a hit so we can stop observing.
let result = checkFn?.();
if (result) {
cleanUp();
resolve(result);
}
};
pollIntervalId = lazy.setInterval(intervalFn, lazy.pollingInterval);
let cleanUp = () => {
lazy.logConsole.debug("#promiseObserve cleanup", {
observer,
pollIntervalId,
href: this.document.location?.href,
});
// Unregister the observer.
if (observer) {
observer.disconnect();
observer = null;
}
// Stop the polling checks.
if (pollIntervalId) {
lazy.clearInterval(pollIntervalId);
pollIntervalId = null;
}
this.#hasActiveObserver = false;
this.#abortController.signal.removeEventListener(
"abort",
abortFunction
);
this.#timeoutSignalController.signal.removeEventListener(
"abort",
timeoutFunction
);
};
let abortFunction = () => {
cleanUp();
reject(this.#abortController.signal.reason);
};
this.#abortController.signal.addEventListener("abort", abortFunction);
let timeoutFunction = () => {
cleanUp();
resolve(null);
};
this.#timeoutSignalController.signal.addEventListener(
"abort",
timeoutFunction
);
});
}
// Detecting if the banner is shown on the page.
async #detectBanner() {
if (!this.#clickRules?.length) {
return [];
}
lazy.logConsole.debug("Starting to detect the banner");
// Returns an array of rules for which a cookie banner exists for the
// current site.
let presenceDetector = () => {
lazy.logConsole.debug("presenceDetector start");
let matchingRules = this.#clickRules.filter(rule => {
let { presence, skipPresenceVisibilityCheck } = rule;
let banner = this.document.querySelector(presence);
lazy.logConsole.debug("Testing banner el presence", {
result: banner,
rule,
presence,
});
if (!banner) {
return false;
}
if (skipPresenceVisibilityCheck) {
return true;
}
return this.#isVisible(banner);
});
// For no rules matched return null explicitly so #promiseObserve knows we
// want to keep observing.
if (!matchingRules.length) {
return null;
}
return matchingRules;
};
lazy.logConsole.debug("Initial call to presenceDetector");
let rules = presenceDetector();
// If we couldn't detect the banner at the beginning, we register an
// observer with the timeout to observe if the banner was shown within the
// timeout.
if (!rules?.length) {
lazy.logConsole.debug(
"Initial presenceDetector failed, registering MutationObserver",
rules
);
rules = await this.#promiseObserve(presenceDetector);
}
if (!rules?.length) {
lazy.logConsole.debug("Couldn't detect the banner", rules);
return [];
}
lazy.logConsole.debug("Detected the banner for rules", rules);
return rules;
}
// Clicking the target button.
async #clickTarget(rules) {
lazy.logConsole.debug("Starting to detect the target button");
let targetEl;
for (let rule of rules) {
targetEl = this.document.querySelector(rule.target);
if (targetEl) {
break;
}
}
// The target button is not available. We register an observer to wait until
// it's ready.
if (!targetEl) {
targetEl = await this.#promiseObserve(() => {
for (let rule of rules) {
let el = this.document.querySelector(rule.target);
lazy.logConsole.debug("Testing button el presence", {
result: el,
rule,
target: rule.target,
});
if (el) {
lazy.logConsole.debug(
"Found button from rule",
rule,
rule.target,
el
);
return el;
}
}
return null;
});
if (!targetEl) {
lazy.logConsole.debug("Cannot find the target button.");
return false;
}
}
lazy.logConsole.debug("Found the target button, click it.", targetEl);
targetEl.click();
return true;
}
// The helper function to check if the given element if visible.
#isVisible(element) {
return element.checkVisibility({
checkOpacity: true,
checkVisibilityCSS: true,
});
}
#maybeSendTestMessage() {
if (lazy.testing) {
let win = this.contentWindow;
// Report the clicking is finished after the style has been flushed.
win.requestAnimationFrame(() => {
win.setTimeout(() => {
this.sendAsyncMessage("CookieBanner::Test-FinishClicking");
}, 0);
});
}
}
}