From c61ac2d26134e5e3e630a8923627616e4e9a6ade Mon Sep 17 00:00:00 2001 From: Peter Snyder Date: Mon, 10 Feb 2020 16:34:42 -0800 Subject: [PATCH 1/4] Cosmetic filtering rules are applied by default and "un-hidden" when they are found to be 1st-party Fixes layout re-flow Co-authored-by: Pete Miller --- .../background/api/cosmeticFilterAPI.ts | 25 ++- .../background/events/cosmeticFilterEvents.ts | 6 +- .../brave_extension/content_cosmetic.ts | 160 ++++++++++++------ 3 files changed, 132 insertions(+), 59 deletions(-) diff --git a/components/brave_extension/extension/brave_extension/background/api/cosmeticFilterAPI.ts b/components/brave_extension/extension/brave_extension/background/api/cosmeticFilterAPI.ts index 617673edf833..9fbf8246ba35 100644 --- a/components/brave_extension/extension/brave_extension/background/api/cosmeticFilterAPI.ts +++ b/components/brave_extension/extension/brave_extension/background/api/cosmeticFilterAPI.ts @@ -17,22 +17,26 @@ const informTabOfCosmeticRulesToConsider = (tabId: number, hideRules: string[]) } } +// Fires when content-script calls hiddenClassIdSelectors export const injectClassIdStylesheet = (tabId: number, classes: string[], ids: string[], exceptions: string[]) => { chrome.braveShields.hiddenClassIdSelectors(classes, ids, exceptions, (jsonSelectors) => { - const hideSelectors = JSON.parse(jsonSelectors) - informTabOfCosmeticRulesToConsider(tabId, hideSelectors) + const selectors = JSON.parse(jsonSelectors) + informTabOfCosmeticRulesToConsider(tabId, selectors) + hideSelectors(tabId, selectors) }) } +// Fires on content-script loaded export const applyAdblockCosmeticFilters = (tabId: number, hostname: string) => { chrome.braveShields.hostnameCosmeticResources(hostname, async (resources) => { if (chrome.runtime.lastError) { - console.warn('Unable to get cosmetic filter data for the current host') + console.warn('Unable to get cosmetic filter data for the current host', chrome.runtime.lastError) return } informTabOfCosmeticRulesToConsider(tabId, resources.hide_selectors) + hideSelectors(tabId, resources.hide_selectors) let styledStylesheet = '' for (const selector in resources.style_selectors) { styledStylesheet += selector + '{' + resources.style_selectors[selector].join(';') + ';}\n' @@ -54,9 +58,20 @@ export const applyAdblockCosmeticFilters = (tabId: number, hostname: string) => }) } -export const hideThirdPartySelectors = (tabId: number, selectors: string[]) => { +const cssVarNameOverrideDisplay = '--brave-shields-filter-override-display' + +function hideSelectors (tabId: number, selectors: string[]) { + const code = `${selectors.join(',')}{${cssVarNameOverrideDisplay}: none !important;display:var(${cssVarNameOverrideDisplay}) !important;}` + chrome.tabs.insertCSS(tabId, { + code: `${selectors.join(',')}{${cssVarNameOverrideDisplay}: none !important;display:var(${cssVarNameOverrideDisplay}) !important;}`, + cssOrigin: 'user', + runAt: 'document_start' + }) +} + +export const showFirstPartySelectors = (tabId: number, selectors: string[]) => { chrome.tabs.insertCSS(tabId, { - code: `${selectors.join(',')}{display:none!important;}`, + code: `${selectors.join(',')}{${cssVarNameOverrideDisplay}: invalid !important;}`, cssOrigin: 'user', runAt: 'document_start' }) diff --git a/components/brave_extension/extension/brave_extension/background/events/cosmeticFilterEvents.ts b/components/brave_extension/extension/brave_extension/background/events/cosmeticFilterEvents.ts index 4202775eaa88..f54aa47b04bf 100644 --- a/components/brave_extension/extension/brave_extension/background/events/cosmeticFilterEvents.ts +++ b/components/brave_extension/extension/brave_extension/background/events/cosmeticFilterEvents.ts @@ -3,7 +3,7 @@ import { addSiteCosmeticFilter, removeSiteFilter, removeAllFilters, - hideThirdPartySelectors + showFirstPartySelectors } from '../api/cosmeticFilterAPI' import shieldsPanelActions from '../actions/shieldsPanelActions' @@ -63,7 +63,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { shieldsPanelActions.generateClassIdStylesheet(tabId, msg.classes, msg.ids) break } - case 'hideThirdPartySelectors': { + case 'showFirstPartySelectors': { const tab = sender.tab if (tab === undefined) { break @@ -80,7 +80,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (selectors === undefined) { break } - hideThirdPartySelectors(tabId, selectors) + showFirstPartySelectors(tabId, selectors) break } case 'contentScriptsLoaded': { diff --git a/components/brave_extension/extension/brave_extension/content_cosmetic.ts b/components/brave_extension/extension/brave_extension/content_cosmetic.ts index f69be3e81708..d804fbe87ef4 100644 --- a/components/brave_extension/extension/brave_extension/content_cosmetic.ts +++ b/components/brave_extension/extension/brave_extension/content_cosmetic.ts @@ -10,13 +10,20 @@ chrome.runtime.sendMessage({ const parseDomain = require('parse-domain') -const queriedIds = new Set() -const queriedClasses = new Set() +// Don't start looking for things to unblock until at least this long after +// the backend script is up and connected (eg backgroundReady = true) +const numMSBeforeStart = 2500 +// Only let the mutation observer run for this long After this point, the +// queues are frozen and the mutation observer will stop / disconnect. +const numMSCheckFor = 15000 -let notYetQueriedClasses: string[] = [] -let notYetQueriedIds: string[] = [] +const queriedIds = new Set() +const queriedClasses = new Set() -let backgroundReady: boolean = false +// Each of these get setup once the mutation observer starts running. +let notYetQueriedClasses: string[] +let notYetQueriedIds: string[] +let cosmeticObserver: MutationObserver | undefined = undefined function isElement (node: Node): boolean { return (node.nodeType === 1) @@ -35,22 +42,17 @@ function asHTMLElement (node: Node): HTMLElement | null { } const fetchNewClassIdRules = function () { - // Only let the backend know that we've found new classes and id attributes - // if the back end has told us its ready to go and we have at least one new - // class or id to query for. - if (backgroundReady) { - if (notYetQueriedClasses.length !== 0 || notYetQueriedIds.length !== 0) { - chrome.runtime.sendMessage({ - type: 'hiddenClassIdSelectors', - classes: notYetQueriedClasses, - ids: notYetQueriedIds - }) - notYetQueriedClasses = [] - notYetQueriedIds = [] - } - } else { - setTimeout(fetchNewClassIdRules, 100) + if ((!notYetQueriedClasses || notYetQueriedClasses.length === 0) && + (!notYetQueriedIds || notYetQueriedIds.length === 0)) { + return } + chrome.runtime.sendMessage({ + type: 'hiddenClassIdSelectors', + classes: notYetQueriedClasses || [], + ids: notYetQueriedIds || [] + }) + notYetQueriedClasses = [] + notYetQueriedIds = [] } const handleMutations: MutationCallback = function (mutations: MutationRecord[]) { @@ -104,13 +106,6 @@ const handleMutations: MutationCallback = function (mutations: MutationRecord[]) fetchNewClassIdRules() } -const cosmeticObserver = new MutationObserver(handleMutations) -let observerConfig = { - subtree: true, - childList: true, - attributeFilter: ['id', 'class'] -} -cosmeticObserver.observe(document.documentElement, observerConfig) const _parseDomainCache = Object.create(null) const getParsedDomain = (aDomain: string) => { @@ -151,19 +146,19 @@ interface IsFirstPartyQueryResult { } /** - * Determine whether a given subtree should be considered as "first party" content. + * Determine whether a subtree should be considered as "first party" content. * * Uses the following process in making this determination. - * - If the subtree contains any first party resources, the subtree is first party. + * - If the subtree contains any first party resources, the subtree is 1p. * - If the subtree contains no remote resources, the subtree is first party. * - Otherwise, its 3rd party. * - * Note that any instances of "url(" or escape characters in style attributes are - * automatically treated as third-party URLs. These patterns and special cases - * were generated from looking at patterns in ads with resources in the style - * attribute. + * Note that any instances of "url(" or escape characters in style attributes + * are automatically treated as third-party URLs. These patterns and special + * cases were generated from looking at patterns in ads with resources in the + * style attribute. * - * Similarly, an empty srcdoc attribute is also considered third party, since many + * Similarly, an empty srcdoc attribute is also considered 3p, since many * third party ads clear this attribute in practice. * * Finally, special case some ids we know are used only for third party ads. @@ -255,19 +250,19 @@ const isSubTreeFirstParty = (elm: Element, possibleQueryResult?: IsFirstPartyQue return true } -const hideSelectors = (selectors: string[]) => { +const unhideSelectors = (selectors: string[]) => { if (selectors.length === 0) { return } chrome.runtime.sendMessage({ - type: 'hideThirdPartySelectors', + type: 'showFirstPartySelectors', selectors }) } -const alreadyHiddenSelectors = new Set() -const alreadyHiddenThirdPartySubTrees = new WeakSet() +const alreadyUnhiddenSelectors = new Set() +const alreadyKnownFirstPartySubtrees = new WeakSet() const allSelectorsSet = new Set() const firstRunQueue = new Set() const secondRunQueue = new Set() @@ -279,11 +274,14 @@ const maxWorkSize = 50 let queueIsSleeping = false const pumpCosmeticFilterQueues = () => { - if (queueIsSleeping) { + if (queueIsSleeping === true) { return } let didPumpAnything = false + // For each "pump", walk through each queue until we find selectors + // to evaluate. This means that nothing in queue N+1 will be evaluated + // until queue N is completely empty. for (let queueIndex = 0; queueIndex < numQueues; queueIndex += 1) { const currentQueue = allQueues[queueIndex] const nextQueue = allQueues[queueIndex + 1] @@ -294,29 +292,53 @@ const pumpCosmeticFilterQueues = () => { const currentWorkLoad = Array.from(currentQueue.values()).slice(0, maxWorkSize) const comboSelector = currentWorkLoad.join(',') const matchingElms = document.querySelectorAll(comboSelector) - const selectorsToHide = [] + // Will hold selectors identified by _this_ queue pumping, that were + // newly identified to be matching 1p content. Will be sent to + // the background script to do the un-hiding. + const newlyIdentifiedFirstPartySelectors: string[] = [] for (const aMatchingElm of matchingElms) { - if (alreadyHiddenThirdPartySubTrees.has(aMatchingElm)) { + // Don't recheck elements / subtrees we already know are first party. + // Once we know something is third party, we never need to evaluate it + // again. + if (alreadyKnownFirstPartySubtrees.has(aMatchingElm)) { continue } + const elmSubtreeIsFirstParty = isSubTreeFirstParty(aMatchingElm) + // If we find that a subtree is third party, then no need to change + // anything, leave the selector as "hiding" and move on. if (elmSubtreeIsFirstParty === false) { - for (const selector of currentWorkLoad) { - if (aMatchingElm.matches(selector) && !alreadyHiddenSelectors.has(selector)) { - selectorsToHide.push(selector) - alreadyHiddenSelectors.add(selector) - } + continue + } + // Otherwise, we know that the given subtree was evaluated to be + // first party, so we need to figure out which selector from the combo + // selector did the matching. + for (const selector of currentWorkLoad) { + if (aMatchingElm.matches(selector) === false) { + continue + } + + // Similarly, if we already know a selector matches 1p content, + // there is no need to notify the background script again, so + // we don't need to consider further. + if (alreadyUnhiddenSelectors.has(selector) === true) { + continue } - alreadyHiddenThirdPartySubTrees.add(aMatchingElm) + + newlyIdentifiedFirstPartySelectors.push(selector) + alreadyUnhiddenSelectors.add(selector) } + alreadyKnownFirstPartySubtrees.add(aMatchingElm) } - hideSelectors(selectorsToHide) + unhideSelectors(newlyIdentifiedFirstPartySelectors) for (const aUsedSelector of currentWorkLoad) { currentQueue.delete(aUsedSelector) - if (nextQueue) { + // Don't requeue selectors we know identify first party content. + const selectorMatchedFirstParty = newlyIdentifiedFirstPartySelectors.includes(aUsedSelector) + if (nextQueue && selectorMatchedFirstParty === false) { nextQueue.add(aUsedSelector) } } @@ -334,11 +356,47 @@ const pumpCosmeticFilterQueues = () => { } } +const startObserving = () => { + // First queue up any classes and ids that exist before the mutation observer + // starts running. + const elmWithClassOrId = document.querySelectorAll('[class],[id]') + for (const elm of elmWithClassOrId) { + for (const aClassName of elm.classList.values()) { + queriedClasses.add(aClassName) + } + const elmId = elm.getAttribute('id') + if (elmId) { + queriedIds.add(elmId) + } + } + + notYetQueriedClasses = Array.from(queriedClasses) + notYetQueriedIds = Array.from(queriedIds) + fetchNewClassIdRules() + + // Second, set up a mutation observer to handle any new ids or classes + // that are added to the document. + cosmeticObserver = new MutationObserver(handleMutations) + let observerConfig = { + subtree: true, + childList: true, + attributeFilter: ['id', 'class'] + } + cosmeticObserver.observe(document.documentElement, observerConfig) +} + +const stopObserving = () => { + if (cosmeticObserver) { + cosmeticObserver.disconnect() + } +} + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const action = typeof msg === 'string' ? msg : msg.type switch (action) { case 'cosmeticFilteringBackgroundReady': { - backgroundReady = true + setTimeout(startObserving, numMSBeforeStart) + setTimeout(stopObserving, numMSBeforeStart + numMSCheckFor) break } @@ -355,4 +413,4 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { break } } -}) +}) \ No newline at end of file From 12ac9025de7d7e94af755b351f89e30cedc108f2 Mon Sep 17 00:00:00 2001 From: Pete Miller Date: Mon, 10 Feb 2020 22:01:02 -0800 Subject: [PATCH 2/4] Cosmetic filter rules are added and reset via CSSStyleSheet Since we do not have a chrome.tabs.removeCSS API, we need another method for restoring the page's original css `display` rule value. Adding and removing rule via a constructed CSSStyleSheet injected via document.adoptedStyleSheets serves this purpose. --- .../background/api/cosmeticFilterAPI.ts | 30 +-- .../background/events/cosmeticFilterEvents.ts | 23 +-- .../brave_extension/content_cosmetic.ts | 181 ++++++++++++++---- components/definitions/global.d.ts | 16 ++ 4 files changed, 164 insertions(+), 86 deletions(-) diff --git a/components/brave_extension/extension/brave_extension/background/api/cosmeticFilterAPI.ts b/components/brave_extension/extension/brave_extension/background/api/cosmeticFilterAPI.ts index 9fbf8246ba35..580ea2c6efc7 100644 --- a/components/brave_extension/extension/brave_extension/background/api/cosmeticFilterAPI.ts +++ b/components/brave_extension/extension/brave_extension/background/api/cosmeticFilterAPI.ts @@ -4,11 +4,11 @@ import shieldsPanelActions from '../actions/shieldsPanelActions' -const informTabOfCosmeticRulesToConsider = (tabId: number, hideRules: string[]) => { - if (hideRules.length !== 0) { +const informTabOfCosmeticRulesToConsider = (tabId: number, selectors: string[]) => { + if (selectors.length !== 0) { const message = { - type: 'cosmeticFilterConsiderNewRules', - hideRules + type: 'cosmeticFilterConsiderNewSelectors', + selectors } const options = { frameId: 0 @@ -22,7 +22,6 @@ export const injectClassIdStylesheet = (tabId: number, classes: string[], ids: s chrome.braveShields.hiddenClassIdSelectors(classes, ids, exceptions, (jsonSelectors) => { const selectors = JSON.parse(jsonSelectors) informTabOfCosmeticRulesToConsider(tabId, selectors) - hideSelectors(tabId, selectors) }) } @@ -35,8 +34,6 @@ export const applyAdblockCosmeticFilters = (tabId: number, hostname: string) => } informTabOfCosmeticRulesToConsider(tabId, resources.hide_selectors) - - hideSelectors(tabId, resources.hide_selectors) let styledStylesheet = '' for (const selector in resources.style_selectors) { styledStylesheet += selector + '{' + resources.style_selectors[selector].join(';') + ';}\n' @@ -58,25 +55,6 @@ export const applyAdblockCosmeticFilters = (tabId: number, hostname: string) => }) } -const cssVarNameOverrideDisplay = '--brave-shields-filter-override-display' - -function hideSelectors (tabId: number, selectors: string[]) { - const code = `${selectors.join(',')}{${cssVarNameOverrideDisplay}: none !important;display:var(${cssVarNameOverrideDisplay}) !important;}` - chrome.tabs.insertCSS(tabId, { - code: `${selectors.join(',')}{${cssVarNameOverrideDisplay}: none !important;display:var(${cssVarNameOverrideDisplay}) !important;}`, - cssOrigin: 'user', - runAt: 'document_start' - }) -} - -export const showFirstPartySelectors = (tabId: number, selectors: string[]) => { - chrome.tabs.insertCSS(tabId, { - code: `${selectors.join(',')}{${cssVarNameOverrideDisplay}: invalid !important;}`, - cssOrigin: 'user', - runAt: 'document_start' - }) -} - // User generated cosmetic filtering below export const applyCSSCosmeticFilters = (tabId: number, hostname: string) => { chrome.storage.local.get('cosmeticFilterList', (storeData = {}) => { diff --git a/components/brave_extension/extension/brave_extension/background/events/cosmeticFilterEvents.ts b/components/brave_extension/extension/brave_extension/background/events/cosmeticFilterEvents.ts index f54aa47b04bf..075e24f47aa7 100644 --- a/components/brave_extension/extension/brave_extension/background/events/cosmeticFilterEvents.ts +++ b/components/brave_extension/extension/brave_extension/background/events/cosmeticFilterEvents.ts @@ -2,8 +2,7 @@ import { getLocale } from '../api/localeAPI' import { addSiteCosmeticFilter, removeSiteFilter, - removeAllFilters, - showFirstPartySelectors + removeAllFilters } from '../api/cosmeticFilterAPI' import shieldsPanelActions from '../actions/shieldsPanelActions' @@ -63,26 +62,6 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { shieldsPanelActions.generateClassIdStylesheet(tabId, msg.classes, msg.ids) break } - case 'showFirstPartySelectors': { - const tab = sender.tab - if (tab === undefined) { - break - } - const tabId = tab.id - if (tabId === undefined) { - break - } - const url = tab.url - if (url === undefined) { - break - } - const selectors = msg.selectors - if (selectors === undefined) { - break - } - showFirstPartySelectors(tabId, selectors) - break - } case 'contentScriptsLoaded': { const tab = sender.tab if (tab === undefined) { diff --git a/components/brave_extension/extension/brave_extension/content_cosmetic.ts b/components/brave_extension/extension/brave_extension/content_cosmetic.ts index d804fbe87ef4..424887152b83 100644 --- a/components/brave_extension/extension/brave_extension/content_cosmetic.ts +++ b/components/brave_extension/extension/brave_extension/content_cosmetic.ts @@ -1,3 +1,8 @@ +// Copyright (c) 2020 The Brave Authors. All rights reserved. +// 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/. + // Notify the background script as soon as the content script has loaded. // chrome.tabs.insertCSS may sometimes fail to inject CSS in a newly navigated // page when using the chrome.webNavigation API. @@ -10,12 +15,10 @@ chrome.runtime.sendMessage({ const parseDomain = require('parse-domain') -// Don't start looking for things to unblock until at least this long after -// the backend script is up and connected (eg backgroundReady = true) -const numMSBeforeStart = 2500 -// Only let the mutation observer run for this long After this point, the -// queues are frozen and the mutation observer will stop / disconnect. -const numMSCheckFor = 15000 +// Start looking for things to unhide before at most this long after +// the backend script is up and connected (eg backgroundReady = true), +// or sooner if the thread is idle. +const maxTimeMSBeforeStart = 2500 const queriedIds = new Set() const queriedClasses = new Set() @@ -25,6 +28,28 @@ let notYetQueriedClasses: string[] let notYetQueriedIds: string[] let cosmeticObserver: MutationObserver | undefined = undefined +const allSelectorsToRules = new Map() +const cosmeticStyleSheet = new CSSStyleSheet() + +/** + * Provides a new function which can only be scheduled once at a time. + * + * @param onIdle function to run when the thread is less busy + * @param timeout max time to wait. at or after this time the function will be run regardless of thread noise + */ +function idleize (onIdle: Function, timeout: number) { + let idleId: number | undefined = undefined + return function WillRunOnIdle () { + if (idleId !== undefined) { + return + } + idleId = window.requestIdleCallback(() => { + idleId = undefined + onIdle() + }, { timeout }) + } +} + function isElement (node: Node): boolean { return (node.nodeType === 1) } @@ -106,7 +131,6 @@ const handleMutations: MutationCallback = function (mutations: MutationRecord[]) fetchNewClassIdRules() } - const _parseDomainCache = Object.create(null) const getParsedDomain = (aDomain: string) => { const cacheResult = _parseDomainCache[aDomain] @@ -129,7 +153,7 @@ const isFirstPartyUrl = (url: string): boolean => { if (!parsedTargetDomain) { // If we cannot determine the party-ness of the resource, // consider it first-party. - console.debug(`Unable to determine party-ness of "${url}"`) + console.debug(`Cosmetic filtering: Unable to determine party-ness of "${url}"`) return false } @@ -250,29 +274,70 @@ const isSubTreeFirstParty = (elm: Element, possibleQueryResult?: IsFirstPartyQue return true } -const unhideSelectors = (selectors: string[]) => { - if (selectors.length === 0) { +const unhideSelectors = (selectors: Set) => { + if (selectors.size === 0) { return } - - chrome.runtime.sendMessage({ - type: 'showFirstPartySelectors', - selectors - }) + // Find selectors we have a rule index for + const rulesToRemove = Array.from(selectors) + .map(selector => + allSelectorsToRules.get(selector) + ) + .filter(i => i !== undefined) + .sort() + .reverse() + // Delete the rules + let lastIdx: number = allSelectorsToRules.size - 1 + for (const ruleIdx of rulesToRemove) { + cosmeticStyleSheet.deleteRule(ruleIdx) + } + // Re-sync the indexes + // TODO: Sync is hard, just re-build by iterating through the StyleSheet rules. + const ruleLookup = Array.from(allSelectorsToRules.entries()) + let countAtLastHighest = rulesToRemove.length + for (let i = lastIdx; i > 0; i--) { + const [selector, oldIdx] = ruleLookup[i] + // Is this one we removed? + if (rulesToRemove.includes(i)) { + allSelectorsToRules.delete(selector) + countAtLastHighest-- + if (countAtLastHighest === 0) { + break + } + continue + } + if (oldIdx !== i) { + // Probably out of sync + console.error('Cosmetic Filters: old index did not match lookup index', { selector, oldIdx, i }) + } + allSelectorsToRules.set(selector, oldIdx - countAtLastHighest) + } } const alreadyUnhiddenSelectors = new Set() const alreadyKnownFirstPartySubtrees = new WeakSet() -const allSelectorsSet = new Set() +// All new selectors go in `firstRunQueue` const firstRunQueue = new Set() +// Third party matches go in the second and third queues. const secondRunQueue = new Set() +// Once a selector gets in to this queue, it's only evaluated for 1p content one +// more time. const finalRunQueue = new Set() const allQueues = [firstRunQueue, secondRunQueue, finalRunQueue] const numQueues = allQueues.length -const pumpIntervalMs = 50 -const maxWorkSize = 50 +const pumpIntervalMinMs = 40 +const pumpIntervalMaxMs = 1000 +const maxWorkSize = 60 let queueIsSleeping = false +/** + * Go through each of the 3 queues, only take 50 items from each one + * 1. Take 50 selects from the first queue with any items + * 2. Determine partyness of matched element: + * - If any are 3rd party, keep 'hide' rule and check again later in next queue. + * - If any are 1st party, remove 'hide' rule and never check selector again. + * 3. If we're looking at the 3rd queue, don't requeue any selectors. + */ const pumpCosmeticFilterQueues = () => { if (queueIsSleeping === true) { return @@ -295,7 +360,7 @@ const pumpCosmeticFilterQueues = () => { // Will hold selectors identified by _this_ queue pumping, that were // newly identified to be matching 1p content. Will be sent to // the background script to do the un-hiding. - const newlyIdentifiedFirstPartySelectors: string[] = [] + const newlyIdentifiedFirstPartySelectors = new Set() for (const aMatchingElm of matchingElms) { // Don't recheck elements / subtrees we already know are first party. @@ -308,6 +373,8 @@ const pumpCosmeticFilterQueues = () => { const elmSubtreeIsFirstParty = isSubTreeFirstParty(aMatchingElm) // If we find that a subtree is third party, then no need to change // anything, leave the selector as "hiding" and move on. + // This element will likely be checked again on the next 'pump' + // as long as another element from the selector does not match 1st party. if (elmSubtreeIsFirstParty === false) { continue } @@ -326,7 +393,7 @@ const pumpCosmeticFilterQueues = () => { continue } - newlyIdentifiedFirstPartySelectors.push(selector) + newlyIdentifiedFirstPartySelectors.add(selector) alreadyUnhiddenSelectors.add(selector) } alreadyKnownFirstPartySubtrees.add(aMatchingElm) @@ -337,25 +404,35 @@ const pumpCosmeticFilterQueues = () => { for (const aUsedSelector of currentWorkLoad) { currentQueue.delete(aUsedSelector) // Don't requeue selectors we know identify first party content. - const selectorMatchedFirstParty = newlyIdentifiedFirstPartySelectors.includes(aUsedSelector) + const selectorMatchedFirstParty = newlyIdentifiedFirstPartySelectors.has(aUsedSelector) if (nextQueue && selectorMatchedFirstParty === false) { nextQueue.add(aUsedSelector) } } didPumpAnything = true + // If we did something, process the next queue, save it for next time. break } if (didPumpAnything) { queueIsSleeping = true - setTimeout(() => { + window.setTimeout(() => { + // Set this to false now even though there's a gap in time between now and + // idle since all other calls to `pumpCosmeticFilterQueuesOnIdle` that occur during this time + // will be ignored (and nothing else should be calling `pumpCosmeticFilterQueues` straight). queueIsSleeping = false - pumpCosmeticFilterQueues() - }, pumpIntervalMs) + // tslint:disable-next-line:no-use-before-declare + pumpCosmeticFilterQueuesOnIdle() + }, pumpIntervalMinMs) } } +const pumpCosmeticFilterQueuesOnIdle = idleize( + pumpCosmeticFilterQueues, + pumpIntervalMaxMs +) + const startObserving = () => { // First queue up any classes and ids that exist before the mutation observer // starts running. @@ -385,32 +462,60 @@ const startObserving = () => { cosmeticObserver.observe(document.documentElement, observerConfig) } -const stopObserving = () => { - if (cosmeticObserver) { - cosmeticObserver.disconnect() +let _hasDelayOcurred: boolean = false +let _startCheckingId: number | undefined = undefined +const scheduleQueuePump = () => { + // Three states possible here. First, the delay has already occurred. If so, + // pass through to pumpCosmeticFilterQueues immediately. + if (_hasDelayOcurred === true) { + pumpCosmeticFilterQueuesOnIdle() + return + } + // Second possibility is that we're already waiting for the delay to pass / + // occur. In this case, do nothing. + if (_startCheckingId !== undefined) { + return } + // Third / final possibility, this is this the first time this has been + // called, in which case set up a timmer and quit + _startCheckingId = window.requestIdleCallback(function ({ didTimeout }) { + _hasDelayOcurred = true + startObserving() + pumpCosmeticFilterQueuesOnIdle() + }, { timeout: maxTimeMSBeforeStart }) } chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const action = typeof msg === 'string' ? msg : msg.type switch (action) { case 'cosmeticFilteringBackgroundReady': { - setTimeout(startObserving, numMSBeforeStart) - setTimeout(stopObserving, numMSBeforeStart + numMSCheckFor) + scheduleQueuePump() break } - - case 'cosmeticFilterConsiderNewRules': { - const { hideRules } = msg - for (const aHideRule of hideRules) { - if (allSelectorsSet.has(aHideRule)) { + case 'cosmeticFilterConsiderNewSelectors': { + const { selectors } = msg + let nextIndex = cosmeticStyleSheet.rules.length + for (const selector of selectors) { + if (allSelectorsToRules.has(selector)) { continue } - allSelectorsSet.add(aHideRule) - firstRunQueue.add(aHideRule) + // insertRule always adds to index 0, + // so we always add to end of list manually. + cosmeticStyleSheet.insertRule( + `${selector}{display:none !important;}`, + nextIndex + ) + allSelectorsToRules.set(selector, nextIndex) + nextIndex++ + firstRunQueue.add(selector) + } + // @ts-ignore + if (!document.adoptedStyleSheets.includes(cosmeticStyleSheet)) { + // @ts-ignore + document.adoptedStyleSheets = [cosmeticStyleSheet] } - pumpCosmeticFilterQueues() + scheduleQueuePump() break } } -}) \ No newline at end of file +}) diff --git a/components/definitions/global.d.ts b/components/definitions/global.d.ts index 7b9e79dc035d..7cef7cf5e3bf 100644 --- a/components/definitions/global.d.ts +++ b/components/definitions/global.d.ts @@ -12,8 +12,24 @@ type loadTimeData = { data_: Record } +type RequestIdleCallbackHandle = any +type RequestIdleCallbackOptions = { + timeout: number +} +type RequestIdleCallbackDeadline = { + readonly didTimeout: boolean; + timeRemaining: (() => number) +} + declare global { interface Window { + // Typescript doesn't include requestIdleCallback as it's non-standard. + // Since it's supported in Chromium, we can include it here. + requestIdleCallback: (( + callback: ((deadline: RequestIdleCallbackDeadline) => void), + opts?: RequestIdleCallbackOptions + ) => RequestIdleCallbackHandle) + cancelIdleCallback: ((handle: RequestIdleCallbackHandle) => void) loadTimeData: loadTimeData cr: { define: (name: string, init: () => void) => void From cde3d637fb53a2d11aa3a11c3954838b87515674 Mon Sep 17 00:00:00 2001 From: Peter Snyder Date: Tue, 11 Feb 2020 10:57:53 -0800 Subject: [PATCH 3/4] Cosmetic filtering aims to hide empty 'advertisement' title leftovers --- .../brave_extension/content_cosmetic.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/components/brave_extension/extension/brave_extension/content_cosmetic.ts b/components/brave_extension/extension/brave_extension/content_cosmetic.ts index 424887152b83..0d381ed96287 100644 --- a/components/brave_extension/extension/brave_extension/content_cosmetic.ts +++ b/components/brave_extension/extension/brave_extension/content_cosmetic.ts @@ -20,6 +20,12 @@ const parseDomain = require('parse-domain') // or sooner if the thread is idle. const maxTimeMSBeforeStart = 2500 +// The cutoff for text ads. If something has only text in it, it needs to have +// this many, or more, characters. Similarly, require it to have a non-trivial +// number of words in it, to look like an actual text ad. +const minAdTextChars = 30 +const minAdTextWords = 5 + const queriedIds = new Set() const queriedClasses = new Set() @@ -163,6 +169,17 @@ const isFirstPartyUrl = (url: string): boolean => { ) } +const isAdText = (text: string): boolean => { + const trimmedText = text.trim() + if (trimmedText.length < minAdTextChars) { + return false + } + if (trimmedText.split(' ').length < minAdTextWords) { + return false + } + return true +} + interface IsFirstPartyQueryResult { foundFirstPartyResource: boolean, foundThirdPartyResource: boolean, @@ -268,7 +285,7 @@ const isSubTreeFirstParty = (elm: Element, possibleQueryResult?: IsFirstPartyQue return false } const htmlElement = asHTMLElement(elm) - if (!htmlElement || !htmlElement.innerText.trim().length) { + if (!htmlElement || isAdText(htmlElement.innerText) === false) { return false } return true From 36c8577ac43b3ace2fa4f4a595f27ba4ee485b9f Mon Sep 17 00:00:00 2001 From: Pete Miller Date: Thu, 13 Feb 2020 22:14:15 -0800 Subject: [PATCH 4/4] Cosmetic Filtering fix relative url decision --- .../extension/brave_extension/content_cosmetic.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/components/brave_extension/extension/brave_extension/content_cosmetic.ts b/components/brave_extension/extension/brave_extension/content_cosmetic.ts index 0d381ed96287..c64e3c888b8a 100644 --- a/components/brave_extension/extension/brave_extension/content_cosmetic.ts +++ b/components/brave_extension/extension/brave_extension/content_cosmetic.ts @@ -56,6 +56,14 @@ function idleize (onIdle: Function, timeout: number) { } } +function isRelativeUrl (url: string): boolean { + return ( + !url.startsWith('//') && + !url.startsWith('http://') && + !url.startsWith('https://') + ) +} + function isElement (node: Node): boolean { return (node.nodeType === 1) } @@ -151,7 +159,7 @@ const getParsedDomain = (aDomain: string) => { const _parsedCurrentDomain = getParsedDomain(window.location.host) const isFirstPartyUrl = (url: string): boolean => { - if (url.startsWith('/')) { + if (isRelativeUrl(url)) { return true }