From 9c3205b37cb62a8e2770e03e072557c8740a38c7 Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Wed, 17 Feb 2021 09:12:00 -0500 Subject: [PATCH] Inject procedural cosmetic filterer's code only when needed The procedural cosmetic filtering code has been split from the content script code injected unconditionally and will from now on be injected only when it is needed, i.e. when there are procedural cosmetic filters to enforce. The motivation for this is: https://www.debugbear.com/blog/2020-chrome-extension-performance-report#what-can-extension-developers-do-to-keep-their-extensions-fast Though uBO's content script injected unconditionally in all pages/frames is relatively small, I still wanted to further reduce the amount of content script code injected unconditionally: The procedural cosmetic filtering code represents roughly 14KB of code the browser won't have to parse/execute unconditionally unless there exists procedural cosmetic filters to enforce for a page or frame. At the time the above article was published, the total size of unconditional content scripts injected by uBO was ~101 KB, while after this commit, the total size will be ~57 KB (keeping in mind uBO does not minify and does not remove comments from its JavaScript code). Additionally, some refactoring on how user stylesheets are injected so as to ensure that `:style`-based procedural filters which are essentially declarative are injected earlier along with plain, non-procedural cosmetic filters. --- src/js/contentscript-extra.js | 469 ++++++++++++++++++++++ src/js/contentscript.js | 733 ++++++++-------------------------- src/js/cosmetic-filtering.js | 50 ++- src/js/messaging.js | 78 +++- src/js/scriptlets/epicker.js | 5 +- src/js/ublock.js | 16 - 6 files changed, 727 insertions(+), 624 deletions(-) create mode 100644 src/js/contentscript-extra.js diff --git a/src/js/contentscript-extra.js b/src/js/contentscript-extra.js new file mode 100644 index 000000000..04ba570c9 --- /dev/null +++ b/src/js/contentscript-extra.js @@ -0,0 +1,469 @@ +/******************************************************************************* + + uBlock Origin - a browser extension to block requests. + Copyright (C) 2014-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +'use strict'; + +if ( + typeof vAPI === 'object' && + typeof vAPI.DOMProceduralFilterer !== 'object' +) { +// >>>>>>>> start of local scope + +/******************************************************************************/ + +// TODO: Experiment/evaluate loading procedural operator code using an +// on demand approach. + +// 'P' stands for 'Procedural' + +const PSelectorHasTextTask = class { + constructor(task) { + let arg0 = task[1], arg1; + if ( Array.isArray(task[1]) ) { + arg1 = arg0[1]; arg0 = arg0[0]; + } + this.needle = new RegExp(arg0, arg1); + } + transpose(node, output) { + if ( this.needle.test(node.textContent) ) { + output.push(node); + } + } +}; + +const PSelectorIfTask = class { + constructor(task) { + this.pselector = new PSelector(task[1]); + } + transpose(node, output) { + if ( this.pselector.test(node) === this.target ) { + output.push(node); + } + } +}; +PSelectorIfTask.prototype.target = true; + +const PSelectorIfNotTask = class extends PSelectorIfTask { +}; +PSelectorIfNotTask.prototype.target = false; + +const PSelectorMatchesCSSTask = class { + constructor(task) { + this.name = task[1].name; + let arg0 = task[1].value, arg1; + if ( Array.isArray(arg0) ) { + arg1 = arg0[1]; arg0 = arg0[0]; + } + this.value = new RegExp(arg0, arg1); + } + transpose(node, output) { + const style = window.getComputedStyle(node, this.pseudo); + if ( style !== null && this.value.test(style[this.name]) ) { + output.push(node); + } + } +}; +PSelectorMatchesCSSTask.prototype.pseudo = null; + +const PSelectorMatchesCSSAfterTask = class extends PSelectorMatchesCSSTask { +}; +PSelectorMatchesCSSAfterTask.prototype.pseudo = ':after'; + +const PSelectorMatchesCSSBeforeTask = class extends PSelectorMatchesCSSTask { +}; +PSelectorMatchesCSSBeforeTask.prototype.pseudo = ':before'; + +const PSelectorMinTextLengthTask = class { + constructor(task) { + this.min = task[1]; + } + transpose(node, output) { + if ( node.textContent.length >= this.min ) { + output.push(node); + } + } +}; + +const PSelectorSpathTask = class { + constructor(task) { + this.spath = task[1]; + this.nth = /^(?:\s*[+~]|:)/.test(this.spath); + if ( this.nth ) { return; } + if ( /^\s*>/.test(this.spath) ) { + this.spath = `:scope ${this.spath.trim()}`; + } + } + qsa(node) { + if ( this.nth === false ) { + return node.querySelectorAll(this.spath); + } + const parent = node.parentElement; + if ( parent === null ) { return; } + let pos = 1; + for (;;) { + node = node.previousElementSibling; + if ( node === null ) { break; } + pos += 1; + } + return parent.querySelectorAll( + `:scope > :nth-child(${pos})${this.spath}` + ); + } + transpose(node, output) { + const nodes = this.qsa(node); + if ( nodes === undefined ) { return; } + for ( const node of nodes ) { + output.push(node); + } + } +}; + +const PSelectorUpwardTask = class { + constructor(task) { + const arg = task[1]; + if ( typeof arg === 'number' ) { + this.i = arg; + } else { + this.s = arg; + } + } + transpose(node, output) { + if ( this.s !== '' ) { + const parent = node.parentElement; + if ( parent === null ) { return; } + node = parent.closest(this.s); + if ( node === null ) { return; } + } else { + let nth = this.i; + for (;;) { + node = node.parentElement; + if ( node === null ) { return; } + nth -= 1; + if ( nth === 0 ) { break; } + } + } + output.push(node); + } +}; +PSelectorUpwardTask.prototype.i = 0; +PSelectorUpwardTask.prototype.s = ''; + +const PSelectorWatchAttrs = class { + constructor(task) { + this.observer = null; + this.observed = new WeakSet(); + this.observerOptions = { + attributes: true, + subtree: true, + }; + const attrs = task[1]; + if ( Array.isArray(attrs) && attrs.length !== 0 ) { + this.observerOptions.attributeFilter = task[1]; + } + } + // TODO: Is it worth trying to re-apply only the current selector? + handler() { + const filterer = + vAPI.domFilterer && vAPI.domFilterer.proceduralFilterer; + if ( filterer instanceof Object ) { + filterer.onDOMChanged([ null ]); + } + } + transpose(node, output) { + output.push(node); + if ( this.observed.has(node) ) { return; } + if ( this.observer === null ) { + this.observer = new MutationObserver(this.handler); + } + this.observer.observe(node, this.observerOptions); + this.observed.add(node); + } +}; + +const PSelectorXpathTask = class { + constructor(task) { + this.xpe = document.createExpression(task[1], null); + this.xpr = null; + } + transpose(node, output) { + this.xpr = this.xpe.evaluate( + node, + XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, + this.xpr + ); + let j = this.xpr.snapshotLength; + while ( j-- ) { + const node = this.xpr.snapshotItem(j); + if ( node.nodeType === 1 ) { + output.push(node); + } + } + } +}; + +const PSelector = class { + constructor(o) { + if ( PSelector.prototype.operatorToTaskMap === undefined ) { + PSelector.prototype.operatorToTaskMap = new Map([ + [ ':has', PSelectorIfTask ], + [ ':has-text', PSelectorHasTextTask ], + [ ':if', PSelectorIfTask ], + [ ':if-not', PSelectorIfNotTask ], + [ ':matches-css', PSelectorMatchesCSSTask ], + [ ':matches-css-after', PSelectorMatchesCSSAfterTask ], + [ ':matches-css-before', PSelectorMatchesCSSBeforeTask ], + [ ':min-text-length', PSelectorMinTextLengthTask ], + [ ':not', PSelectorIfNotTask ], + [ ':nth-ancestor', PSelectorUpwardTask ], + [ ':spath', PSelectorSpathTask ], + [ ':upward', PSelectorUpwardTask ], + [ ':watch-attr', PSelectorWatchAttrs ], + [ ':xpath', PSelectorXpathTask ], + ]); + } + this.raw = o.raw; + this.selector = o.selector; + this.tasks = []; + const tasks = o.tasks; + if ( Array.isArray(tasks) === false ) { return; } + for ( const task of tasks ) { + this.tasks.push( + new (this.operatorToTaskMap.get(task[0]))(task) + ); + } + } + prime(input) { + const root = input || document; + if ( this.selector === '' ) { return [ root ]; } + return Array.from(root.querySelectorAll(this.selector)); + } + exec(input) { + let nodes = this.prime(input); + for ( const task of this.tasks ) { + if ( nodes.length === 0 ) { break; } + const transposed = []; + for ( const node of nodes ) { + task.transpose(node, transposed); + } + nodes = transposed; + } + return nodes; + } + test(input) { + const nodes = this.prime(input); + for ( const node of nodes ) { + let output = [ node ]; + for ( const task of this.tasks ) { + const transposed = []; + for ( const node of output ) { + task.transpose(node, transposed); + } + output = transposed; + if ( output.length === 0 ) { break; } + } + if ( output.length !== 0 ) { return true; } + } + return false; + } +}; +PSelector.prototype.operatorToTaskMap = undefined; + +const PSelectorRoot = class extends PSelector { + constructor(o, styleToken) { + super(o); + this.budget = 200; // I arbitrary picked a 1/5 second + this.raw = o.raw; + this.cost = 0; + this.lastAllowanceTime = 0; + this.styleToken = styleToken; + } +}; +PSelectorRoot.prototype.hit = false; + +const ProceduralFilterer = class { + constructor(domFilterer) { + this.domFilterer = domFilterer; + this.domIsReady = false; + this.domIsWatched = false; + this.mustApplySelectors = false; + this.selectors = new Map(); + this.masterToken = vAPI.randomToken(); + this.styleTokenMap = new Map(); + this.styledNodes = new Set(); + if ( vAPI.domWatcher instanceof Object ) { + vAPI.domWatcher.addListener(this); + } + } + + addProceduralSelectors(selectors) { + const addedSelectors = []; + let mustCommit = this.domIsWatched; + for ( const selector of selectors ) { + if ( this.selectors.has(selector.raw) ) { continue; } + let style, styleToken; + if ( selector.action === undefined ) { + style = vAPI.hideStyle; + } else if ( selector.action[0] === ':style' ) { + style = selector.action[1]; + } + if ( style !== undefined ) { + styleToken = this.styleTokenFromStyle(style); + } + const pselector = new PSelectorRoot(selector, styleToken); + this.selectors.set(selector.raw, pselector); + addedSelectors.push(pselector); + mustCommit = true; + } + if ( mustCommit === false ) { return; } + this.mustApplySelectors = this.selectors.size !== 0; + this.domFilterer.commit(); + if ( this.domFilterer.hasListeners() ) { + this.domFilterer.triggerListeners({ + procedural: addedSelectors + }); + } + } + + commitNow() { + if ( this.selectors.size === 0 || this.domIsReady === false ) { + return; + } + + this.mustApplySelectors = false; + + //console.time('procedural selectors/dom layout changed'); + + // https://github.com/uBlockOrigin/uBlock-issues/issues/341 + // Be ready to unhide nodes which no longer matches any of + // the procedural selectors. + const toUnstyle = this.styledNodes; + this.styledNodes = new Set(); + + let t0 = Date.now(); + + for ( const pselector of this.selectors.values() ) { + const allowance = Math.floor((t0 - pselector.lastAllowanceTime) / 2000); + if ( allowance >= 1 ) { + pselector.budget += allowance * 50; + if ( pselector.budget > 200 ) { pselector.budget = 200; } + pselector.lastAllowanceTime = t0; + } + if ( pselector.budget <= 0 ) { continue; } + const nodes = pselector.exec(); + const t1 = Date.now(); + pselector.budget += t0 - t1; + if ( pselector.budget < -500 ) { + console.info('uBO: disabling %s', pselector.raw); + pselector.budget = -0x7FFFFFFF; + } + t0 = t1; + if ( nodes.length === 0 ) { continue; } + pselector.hit = true; + this.styleNodes(nodes, pselector.styleToken); + } + + this.unstyleNodes(toUnstyle); + //console.timeEnd('procedural selectors/dom layout changed'); + } + + styleTokenFromStyle(style) { + if ( style === undefined ) { return; } + let styleToken = this.styleTokenMap.get(style); + if ( styleToken !== undefined ) { return styleToken; } + styleToken = vAPI.randomToken(); + this.styleTokenMap.set(style, styleToken); + this.domFilterer.addCSS( + `[${this.masterToken}][${styleToken}]\n{${style}}`, + { silent: true, mustInject: true } + ); + return styleToken; + } + + styleNodes(nodes, styleToken) { + if ( styleToken === undefined ) { + for ( const node of nodes ) { + node.textContent = ''; + node.remove(); + } + return; + } + for ( const node of nodes ) { + node.setAttribute(this.masterToken, ''); + node.setAttribute(styleToken, ''); + this.styledNodes.add(node); + } + } + + // TODO: Current assumption is one style per hit element. Could be an + // issue if an element has multiple styling and one styling is + // brough back. Possibly too rare to care about this for now. + unstyleNodes(nodes) { + for ( const node of nodes ) { + if ( this.styledNodes.has(node) ) { continue; } + node.removeAttribute(this.masterToken); + } + } + + createProceduralFilter(o) { + return new PSelectorRoot(o); + } + + onDOMCreated() { + this.domIsReady = true; + this.domFilterer.commit(); + } + + onDOMChanged(addedNodes, removedNodes) { + if ( this.selectors.size === 0 ) { return; } + this.mustApplySelectors = + this.mustApplySelectors || + addedNodes.length !== 0 || + removedNodes; + this.domFilterer.commit(); + } +}; + +vAPI.DOMProceduralFilterer = ProceduralFilterer; + +/******************************************************************************/ + +// >>>>>>>> end of local scope +} + + + + + + + + +/******************************************************************************* + + DO NOT: + - Remove the following code + - Add code beyond the following code + Reason: + - https://github.com/gorhill/uBlock/pull/3721 + - uBO never uses the return value from injected content scripts + +**/ + +void 0; diff --git a/src/js/contentscript.js b/src/js/contentscript.js index 3656b4177..20514fec3 100644 --- a/src/js/contentscript.js +++ b/src/js/contentscript.js @@ -484,612 +484,195 @@ vAPI.injectScriptlet = function(doc, text) { */ -{ - vAPI.hideStyle = 'display:none!important;'; +vAPI.hideStyle = 'display:none!important;'; - // TODO: Experiment/evaluate loading procedural operator code using an - // on demand approach. +vAPI.DOMFilterer = class { + constructor() { + this.commitTimer = new vAPI.SafeAnimationFrame( + ( ) => { this.commitNow(); } + ); + this.domIsReady = document.readyState !== 'loading'; + this.disabled = false; + this.listeners = []; + this.stylesheets = []; + this.exceptedCSSRules = []; + this.exceptions = []; + this.proceduralFilterer = null; + // https://github.com/uBlockOrigin/uBlock-issues/issues/167 + // By the time the DOMContentLoaded is fired, the content script might + // have been disconnected from the background page. Unclear why this + // would happen, so far seems to be a Chromium-specific behavior at + // launch time. + if ( this.domIsReady !== true ) { + document.addEventListener('DOMContentLoaded', ( ) => { + if ( vAPI instanceof Object === false ) { return; } + this.domIsReady = true; + this.commit(); + }); + } + } - // 'P' stands for 'Procedural' + explodeCSS(css) { + const out = []; + const reBlock = /^\{(.*)\}$/m; + const blocks = css.trim().split(/\n\n+/); + for ( const block of blocks ) { + const match = reBlock.exec(block); + out.push([ block.slice(0, match.index).trim(), match[1] ]); + } + return out; + } - const PSelectorHasTextTask = class { - constructor(task) { - let arg0 = task[1], arg1; - if ( Array.isArray(task[1]) ) { - arg1 = arg0[1]; arg0 = arg0[0]; - } - this.needle = new RegExp(arg0, arg1); + addCSS(css, details = {}) { + if ( typeof css !== 'string' || css.length === 0 ) { return; } + if ( this.stylesheets.includes(css) ) { return; } + this.stylesheets.push(css); + if ( details.mustInject && this.disabled === false ) { + vAPI.userStylesheet.add(css); } - transpose(node, output) { - if ( this.needle.test(node.textContent) ) { - output.push(node); - } - } - }; + if ( this.hasListeners() === false ) { return; } + if ( details.silent ) { return; } + this.triggerListeners({ declarative: this.explodeCSS(css) }); + } - const PSelectorIfTask = class { - constructor(task) { - this.pselector = new PSelector(task[1]); + exceptCSSRules(exceptions) { + if ( exceptions.length === 0 ) { return; } + this.exceptedCSSRules.push(...exceptions); + if ( this.hasListeners() ) { + this.triggerListeners({ exceptions }); } - transpose(node, output) { - if ( this.pselector.test(node) === this.target ) { - output.push(node); - } - } - }; - PSelectorIfTask.prototype.target = true; + } - const PSelectorIfNotTask = class extends PSelectorIfTask { - }; - PSelectorIfNotTask.prototype.target = false; + addListener(listener) { + if ( this.listeners.indexOf(listener) !== -1 ) { return; } + this.listeners.push(listener); + } - const PSelectorMatchesCSSTask = class { - constructor(task) { - this.name = task[1].name; - let arg0 = task[1].value, arg1; - if ( Array.isArray(arg0) ) { - arg1 = arg0[1]; arg0 = arg0[0]; - } - this.value = new RegExp(arg0, arg1); - } - transpose(node, output) { - const style = window.getComputedStyle(node, this.pseudo); - if ( style !== null && this.value.test(style[this.name]) ) { - output.push(node); - } - } - }; - PSelectorMatchesCSSTask.prototype.pseudo = null; + removeListener(listener) { + const pos = this.listeners.indexOf(listener); + if ( pos === -1 ) { return; } + this.listeners.splice(pos, 1); + } - const PSelectorMatchesCSSAfterTask = class extends PSelectorMatchesCSSTask { - }; - PSelectorMatchesCSSAfterTask.prototype.pseudo = ':after'; + hasListeners() { + return this.listeners.length !== 0; + } - const PSelectorMatchesCSSBeforeTask = class extends PSelectorMatchesCSSTask { - }; - PSelectorMatchesCSSBeforeTask.prototype.pseudo = ':before'; + triggerListeners(changes) { + for ( const listener of this.listeners ) { + listener.onFiltersetChanged(changes); + } + } - const PSelectorMinTextLengthTask = class { - constructor(task) { - this.min = task[1]; - } - transpose(node, output) { - if ( node.textContent.length >= this.min ) { - output.push(node); - } - } - }; - - const PSelectorSpathTask = class { - constructor(task) { - this.spath = task[1]; - this.nth = /^(?:\s*[+~]|:)/.test(this.spath); - if ( this.nth ) { return; } - if ( /^\s*>/.test(this.spath) ) { - this.spath = `:scope ${this.spath.trim()}`; - } - } - qsa(node) { - if ( this.nth === false ) { - return node.querySelectorAll(this.spath); - } - const parent = node.parentElement; - if ( parent === null ) { return; } - let pos = 1; - for (;;) { - node = node.previousElementSibling; - if ( node === null ) { break; } - pos += 1; - } - return parent.querySelectorAll( - `:scope > :nth-child(${pos})${this.spath}` - ); - } - transpose(node, output) { - const nodes = this.qsa(node); - if ( nodes === undefined ) { return; } - for ( const node of nodes ) { - output.push(node); - } - } - }; - - const PSelectorUpwardTask = class { - constructor(task) { - const arg = task[1]; - if ( typeof arg === 'number' ) { - this.i = arg; + toggle(state, callback) { + if ( state === undefined ) { state = this.disabled; } + if ( state !== this.disabled ) { return; } + this.disabled = !state; + const uss = vAPI.userStylesheet; + for ( const css of this.stylesheets ) { + if ( this.disabled ) { + uss.remove(css); } else { - this.s = arg; + uss.add(css); } } - transpose(node, output) { - if ( this.s !== '' ) { - const parent = node.parentElement; - if ( parent === null ) { return; } - node = parent.closest(this.s); - if ( node === null ) { return; } - } else { - let nth = this.i; - for (;;) { - node = node.parentElement; - if ( node === null ) { return; } - nth -= 1; - if ( nth === 0 ) { break; } - } - } - output.push(node); - } - }; - PSelectorUpwardTask.prototype.i = 0; - PSelectorUpwardTask.prototype.s = ''; + uss.apply(callback); + } - const PSelectorWatchAttrs = class { - constructor(task) { - this.observer = null; - this.observed = new WeakSet(); - this.observerOptions = { - attributes: true, - subtree: true, - }; - const attrs = task[1]; - if ( Array.isArray(attrs) && attrs.length !== 0 ) { - this.observerOptions.attributeFilter = task[1]; - } + // Here we will deal with: + // - Injecting low priority user styles; + // - Notifying listeners about changed filterset. + // https://www.reddit.com/r/uBlockOrigin/comments/9jj0y1/no_longer_blocking_ads/ + // Ensure vAPI is still valid -- it can go away by the time we are + // called, since the port could be force-disconnected from the main + // process. Another approach would be to have vAPI.SafeAnimationFrame + // register a shutdown job: to evaluate. For now I will keep the fix + // trivial. + commitNow() { + this.commitTimer.clear(); + if ( vAPI instanceof Object === false ) { return; } + vAPI.userStylesheet.apply(); + if ( this.proceduralFilterer instanceof Object ) { + this.proceduralFilterer.commitNow(); } - // TODO: Is it worth trying to re-apply only the current selector? - handler() { - const filterer = - vAPI.domFilterer && vAPI.domFilterer.proceduralFilterer; - if ( filterer instanceof Object ) { - filterer.onDOMChanged([ null ]); - } - } - transpose(node, output) { - output.push(node); - if ( this.observed.has(node) ) { return; } - if ( this.observer === null ) { - this.observer = new MutationObserver(this.handler); - } - this.observer.observe(node, this.observerOptions); - this.observed.add(node); - } - }; + } - const PSelectorXpathTask = class { - constructor(task) { - this.xpe = document.createExpression(task[1], null); - this.xpr = null; - } - transpose(node, output) { - this.xpr = this.xpe.evaluate( - node, - XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, - this.xpr - ); - let j = this.xpr.snapshotLength; - while ( j-- ) { - const node = this.xpr.snapshotItem(j); - if ( node.nodeType === 1 ) { - output.push(node); - } - } - } - }; - - const PSelector = class { - constructor(o) { - if ( PSelector.prototype.operatorToTaskMap === undefined ) { - PSelector.prototype.operatorToTaskMap = new Map([ - [ ':has', PSelectorIfTask ], - [ ':has-text', PSelectorHasTextTask ], - [ ':if', PSelectorIfTask ], - [ ':if-not', PSelectorIfNotTask ], - [ ':matches-css', PSelectorMatchesCSSTask ], - [ ':matches-css-after', PSelectorMatchesCSSAfterTask ], - [ ':matches-css-before', PSelectorMatchesCSSBeforeTask ], - [ ':min-text-length', PSelectorMinTextLengthTask ], - [ ':not', PSelectorIfNotTask ], - [ ':nth-ancestor', PSelectorUpwardTask ], - [ ':spath', PSelectorSpathTask ], - [ ':upward', PSelectorUpwardTask ], - [ ':watch-attr', PSelectorWatchAttrs ], - [ ':xpath', PSelectorXpathTask ], - ]); - } - this.raw = o.raw; - this.selector = o.selector; - this.tasks = []; - const tasks = o.tasks; - if ( Array.isArray(tasks) ) { - for ( const task of tasks ) { - this.tasks.push( - new (this.operatorToTaskMap.get(task[0]))(task) - ); - } - } - } - prime(input) { - const root = input || document; - if ( this.selector === '' ) { return [ root ]; } - return Array.from(root.querySelectorAll(this.selector)); - } - exec(input) { - let nodes = this.prime(input); - for ( const task of this.tasks ) { - if ( nodes.length === 0 ) { break; } - const transposed = []; - for ( const node of nodes ) { - task.transpose(node, transposed); - } - nodes = transposed; - } - return nodes; - } - test(input) { - const nodes = this.prime(input); - for ( const node of nodes ) { - let output = [ node ]; - for ( const task of this.tasks ) { - const transposed = []; - for ( const node of output ) { - task.transpose(node, transposed); - } - output = transposed; - if ( output.length === 0 ) { break; } - } - if ( output.length !== 0 ) { return true; } - } - return false; - } - }; - PSelector.prototype.operatorToTaskMap = undefined; - - const PSelectorRoot = class extends PSelector { - constructor(o, styleToken) { - super(o); - this.budget = 200; // I arbitrary picked a 1/5 second - this.raw = o.raw; - this.cost = 0; - this.lastAllowanceTime = 0; - this.styleToken = styleToken; - } - }; - PSelectorRoot.prototype.hit = false; - - const DOMProceduralFilterer = class { - constructor(domFilterer) { - this.domFilterer = domFilterer; - this.domIsReady = false; - this.domIsWatched = false; - this.mustApplySelectors = false; - this.selectors = new Map(); - this.masterToken = vAPI.randomToken(); - this.styleTokenMap = new Map(); - this.styledNodes = new Set(); - if ( vAPI.domWatcher instanceof Object ) { - vAPI.domWatcher.addListener(this); - } - } - - addProceduralSelectors(selectors) { - const addedSelectors = []; - let mustCommit = this.domIsWatched; - for ( const selector of selectors ) { - if ( this.selectors.has(selector.raw) ) { continue; } - let style, styleToken; - if ( selector.action === undefined ) { - style = vAPI.hideStyle; - } else if ( selector.action[0] === ':style' ) { - style = selector.action[1]; - } - if ( style !== undefined ) { - styleToken = this.styleTokenFromStyle(style); - } - const pselector = new PSelectorRoot(selector, styleToken); - this.selectors.set(selector.raw, pselector); - addedSelectors.push(pselector); - mustCommit = true; - } - if ( mustCommit === false ) { return; } - this.mustApplySelectors = this.selectors.size !== 0; - this.domFilterer.commit(); - if ( this.domFilterer.hasListeners() ) { - this.domFilterer.triggerListeners({ - procedural: addedSelectors - }); - } - } - - commitNow() { - if ( this.selectors.size === 0 || this.domIsReady === false ) { - return; - } - - this.mustApplySelectors = false; - - //console.time('procedural selectors/dom layout changed'); - - // https://github.com/uBlockOrigin/uBlock-issues/issues/341 - // Be ready to unhide nodes which no longer matches any of - // the procedural selectors. - const toUnstyle = this.styledNodes; - this.styledNodes = new Set(); - - let t0 = Date.now(); - - for ( const pselector of this.selectors.values() ) { - const allowance = Math.floor((t0 - pselector.lastAllowanceTime) / 2000); - if ( allowance >= 1 ) { - pselector.budget += allowance * 50; - if ( pselector.budget > 200 ) { pselector.budget = 200; } - pselector.lastAllowanceTime = t0; - } - if ( pselector.budget <= 0 ) { continue; } - const nodes = pselector.exec(); - const t1 = Date.now(); - pselector.budget += t0 - t1; - if ( pselector.budget < -500 ) { - console.info('uBO: disabling %s', pselector.raw); - pselector.budget = -0x7FFFFFFF; - } - t0 = t1; - if ( nodes.length === 0 ) { continue; } - pselector.hit = true; - this.styleNodes(nodes, pselector.styleToken); - } - - this.unstyleNodes(toUnstyle); - //console.timeEnd('procedural selectors/dom layout changed'); - } - - styleTokenFromStyle(style) { - if ( style === undefined ) { return; } - let styleToken = this.styleTokenMap.get(style); - if ( styleToken !== undefined ) { return styleToken; } - styleToken = vAPI.randomToken(); - this.styleTokenMap.set(style, styleToken); - this.domFilterer.addCSSRule( - `[${this.masterToken}][${styleToken}]`, - style, - { silent: true, mustInject: true } - ); - return styleToken; - } - - styleNodes(nodes, styleToken) { - if ( styleToken === undefined ) { - for ( const node of nodes ) { - node.textContent = ''; - node.remove(); - } - return; - } - for ( const node of nodes ) { - node.setAttribute(this.masterToken, ''); - node.setAttribute(styleToken, ''); - this.styledNodes.add(node); - } - } - - // TODO: Current assumption is one style per hit element. Could be an - // issue if an element has multiple styling and one styling is - // brough back. Possibly too rare to care about this for now. - unstyleNodes(nodes) { - for ( const node of nodes ) { - if ( this.styledNodes.has(node) ) { continue; } - node.removeAttribute(this.masterToken); - } - } - - createProceduralFilter(o) { - return new PSelectorRoot(o); - } - - onDOMCreated() { - this.domIsReady = true; - this.domFilterer.commit(); - } - - onDOMChanged(addedNodes, removedNodes) { - if ( this.selectors.size === 0 ) { return; } - this.mustApplySelectors = - this.mustApplySelectors || - addedNodes.length !== 0 || - removedNodes; - this.domFilterer.commit(); - } - }; - - vAPI.DOMFilterer = class { - constructor() { - this.commitTimer = new vAPI.SafeAnimationFrame( - ( ) => { this.commitNow(); } - ); - this.domIsReady = document.readyState !== 'loading'; - this.disabled = false; - this.listeners = []; - this.filterset = new Set(); - this.exceptedCSSRules = []; - this.exceptions = []; - this.proceduralFilterer = null; - // https://github.com/uBlockOrigin/uBlock-issues/issues/167 - // By the time the DOMContentLoaded is fired, the content script might - // have been disconnected from the background page. Unclear why this - // would happen, so far seems to be a Chromium-specific behavior at - // launch time. - if ( this.domIsReady !== true ) { - document.addEventListener('DOMContentLoaded', ( ) => { - if ( vAPI instanceof Object === false ) { return; } - this.domIsReady = true; - this.commit(); - }); - } - } - - addCSSRule(selectors, declarations, details = {}) { - if ( selectors === undefined ) { return; } - const selectorsStr = Array.isArray(selectors) - ? selectors.join(',\n') - : selectors; - if ( selectorsStr.length === 0 ) { return; } - this.filterset.add({ selectors: selectorsStr, declarations }); - if ( details.mustInject && this.disabled === false ) { - vAPI.userStylesheet.add(`${selectorsStr}\n{${declarations}}`); - } - this.commit(); - if ( details.silent !== true && this.hasListeners() ) { - this.triggerListeners({ - declarative: [ [ selectorsStr, declarations ] ] - }); - } - } - - exceptCSSRules(exceptions) { - if ( exceptions.length === 0 ) { return; } - this.exceptedCSSRules.push(...exceptions); - if ( this.hasListeners() ) { - this.triggerListeners({ exceptions }); - } - } - - addListener(listener) { - if ( this.listeners.indexOf(listener) !== -1 ) { return; } - this.listeners.push(listener); - } - - removeListener(listener) { - const pos = this.listeners.indexOf(listener); - if ( pos === -1 ) { return; } - this.listeners.splice(pos, 1); - } - - hasListeners() { - return this.listeners.length !== 0; - } - - triggerListeners(changes) { - for ( const listener of this.listeners ) { - listener.onFiltersetChanged(changes); - } - } - - toggle(state, callback) { - if ( state === undefined ) { state = this.disabled; } - if ( state !== this.disabled ) { return; } - this.disabled = !state; - const userStylesheet = vAPI.userStylesheet; - for ( const entry of this.filterset ) { - const rule = `${entry.selectors}\n{${entry.declarations}}`; - if ( this.disabled ) { - userStylesheet.remove(rule); - } else { - userStylesheet.add(rule); - } - } - userStylesheet.apply(callback); - } - - // Here we will deal with: - // - Injecting low priority user styles; - // - Notifying listeners about changed filterset. - // https://www.reddit.com/r/uBlockOrigin/comments/9jj0y1/no_longer_blocking_ads/ - // Ensure vAPI is still valid -- it can go away by the time we are - // called, since the port could be force-disconnected from the main - // process. Another approach would be to have vAPI.SafeAnimationFrame - // register a shutdown job: to evaluate. For now I will keep the fix - // trivial. - commitNow() { + commit(commitNow) { + if ( commitNow ) { this.commitTimer.clear(); - if ( vAPI instanceof Object === false ) { return; } - vAPI.userStylesheet.apply(); - if ( this.proceduralFilterer instanceof Object ) { - this.proceduralFilterer.commitNow(); - } + this.commitNow(); + } else { + this.commitTimer.start(); } + } - commit(commitNow) { - if ( commitNow ) { - this.commitTimer.clear(); - this.commitNow(); - } else { - this.commitTimer.start(); + proceduralFiltererInstance() { + if ( this.proceduralFilterer instanceof Object === false ) { + if ( vAPI.DOMProceduralFilterer instanceof Object === false ) { + return null; } + this.proceduralFilterer = new vAPI.DOMProceduralFilterer(this); } + return this.proceduralFilterer; + } - proceduralFiltererInstance() { - if ( this.proceduralFilterer instanceof Object === false ) { - this.proceduralFilterer = new DOMProceduralFilterer(this); - } - return this.proceduralFilterer; + addProceduralSelectors(selectors) { + if ( Array.isArray(selectors) === false || selectors.length === 0 ) { + return; } - - addProceduralSelectors(selectors) { - if ( Array.isArray(selectors) === false || selectors.length === 0 ) { - return; - } - const procedurals = []; - for ( const raw of selectors ) { - const o = JSON.parse(raw); - if ( - o.action !== undefined && - o.action[0] === ':style' && - o.tasks === undefined - ) { - this.addCSSRule(o.selector, o.action[1], { mustInject: true }); - continue; - } - if ( o.pseudo !== undefined ) { - this.addCSSRule(o.selector, vAPI.hideStyle, { mustInject: true }); - continue; - } - procedurals.push(o); - } - if ( procedurals.length !== 0 ) { - this.proceduralFiltererInstance() - .addProceduralSelectors(procedurals); - } + const procedurals = []; + for ( const raw of selectors ) { + procedurals.push(JSON.parse(raw)); } - - createProceduralFilter(o) { - return this.proceduralFiltererInstance().createProceduralFilter(o); + if ( procedurals.length === 0 ) { return; } + const pfilterer = this.proceduralFiltererInstance(); + if ( pfilterer !== null ) { + pfilterer.addProceduralSelectors(procedurals); } + } - getAllSelectors(bits = 0) { - const out = { - declarative: [], - exceptions: this.exceptedCSSRules, - }; - const hasProcedural = this.proceduralFilterer instanceof Object; - const includePrivateSelectors = (bits & 0b01) !== 0; - const masterToken = hasProcedural - ? `[${this.proceduralFilterer.masterToken}]` - : undefined; - for ( const entry of this.filterset ) { - const selectors = entry.selectors; + createProceduralFilter(o) { + const pfilterer = this.proceduralFiltererInstance(); + if ( pfilterer === null ) { return; } + return pfilterer.createProceduralFilter(o); + } + + getAllSelectors(bits = 0) { + const out = { + declarative: [], + exceptions: this.exceptedCSSRules, + }; + const hasProcedural = this.proceduralFilterer instanceof Object; + const includePrivateSelectors = (bits & 0b01) !== 0; + const masterToken = hasProcedural + ? `[${this.proceduralFilterer.masterToken}]` + : undefined; + for ( const css of this.stylesheets ) { + const blocks = this.explodeCSS(css); + for ( const block of blocks ) { if ( includePrivateSelectors === false && masterToken !== undefined && - selectors.startsWith(masterToken) + block[0].startsWith(masterToken) ) { continue; } - out.declarative.push([ selectors, entry.declarations ]); + out.declarative.push([ block[0], block[1] ]); } - const excludeProcedurals = (bits & 0b10) !== 0; - if ( excludeProcedurals !== true ) { - out.procedural = hasProcedural - ? Array.from(this.proceduralFilterer.selectors.values()) - : []; - } - return out; } + const excludeProcedurals = (bits & 0b10) !== 0; + if ( excludeProcedurals !== true ) { + out.procedural = hasProcedural + ? Array.from(this.proceduralFilterer.selectors.values()) + : []; + } + return out; + } - getAllExceptionSelectors() { - return this.exceptions.join(',\n'); - } - }; -} + getAllExceptionSelectors() { + return this.exceptions.join(',\n'); + } +}; /******************************************************************************/ /******************************************************************************/ @@ -1525,12 +1108,12 @@ vAPI.injectScriptlet = function(doc, text) { let mustCommit = false; if ( result ) { - let selectors = result.injected; - if ( typeof selectors === 'string' && selectors.length !== 0 ) { - domFilterer.addCSSRule(selectors, vAPI.hideStyle); + const css = result.injectedCSS; + if ( typeof css === 'string' && css.length !== 0 ) { + domFilterer.addCSS(css); mustCommit = true; } - selectors = result.excepted; + const selectors = result.excepted; if ( Array.isArray(selectors) && selectors.length !== 0 ) { domFilterer.exceptCSSRules(selectors); } @@ -1695,7 +1278,7 @@ vAPI.injectScriptlet = function(doc, text) { vAPI.domSurveyor = null; } domFilterer.exceptions = cfeDetails.exceptionFilters; - domFilterer.addCSSRule(cfeDetails.injectedHideFilters, vAPI.hideStyle); + domFilterer.addCSS(cfeDetails.injectedCSS); domFilterer.addProceduralSelectors(cfeDetails.proceduralFilters); domFilterer.exceptCSSRules(cfeDetails.exceptedFilters); } diff --git a/src/js/cosmetic-filtering.js b/src/js/cosmetic-filtering.js index 8c1943f7a..4a9e4cabe 100644 --- a/src/js/cosmetic-filtering.js +++ b/src/js/cosmetic-filtering.js @@ -894,7 +894,7 @@ FilterContainer.prototype.retrieveGenericSelectors = function(request) { return; } - const out = { injected: '', excepted, }; + const out = { injectedCSS: '', excepted, }; const injected = []; if ( simpleSelectors.size !== 0 ) { @@ -918,9 +918,9 @@ FilterContainer.prototype.retrieveGenericSelectors = function(request) { }); } - out.injected = injected.join(',\n'); + out.injectedCSS = `${injected.join(',\n')}\n{display:none!important;}`; vAPI.tabs.insertCSS(request.tabId, { - code: out.injected + '\n{display:none!important;}', + code: out.injectedCSS, frameId: request.frameId, matchAboutBlank: true, runAt: 'document_start', @@ -955,9 +955,10 @@ FilterContainer.prototype.retrieveSpecificSelectors = function( exceptedFilters: [], noDOMSurveying: this.needDOMSurveyor === false, }; - const injectedHideFilters = []; + const injectedCSS = []; if ( options.noCosmeticFiltering !== true ) { + const injectedHideFilters = []; const specificSet = this.$specificSet; const proceduralSet = this.$proceduralSet; const exceptionSet = this.$exceptionSet; @@ -1019,8 +1020,29 @@ FilterContainer.prototype.retrieveSpecificSelectors = function( if ( specificSet.size !== 0 ) { injectedHideFilters.push(Array.from(specificSet).join(',\n')); } + + // Some procedural filters are really declarative cosmetic filters, so + // we extract and inject them immediately. if ( proceduralSet.size !== 0 ) { - out.proceduralFilters = Array.from(proceduralSet); + for ( const json of proceduralSet ) { + const pfilter = JSON.parse(json); + if ( pfilter.tasks === undefined ) { + const { action } = pfilter; + if ( action !== undefined && action[0] === ':style' ) { + injectedCSS.push(`${pfilter.selector}\n{${action[1]}}`); + proceduralSet.delete(json); + continue; + } + } + if ( pfilter.pseudo !== undefined ) { + injectedHideFilters.push(pfilter.selector); + proceduralSet.delete(json); + continue; + } + } + if ( proceduralSet.size !== 0 ) { + out.proceduralFilters = Array.from(proceduralSet); + } } // Highly generic cosmetic filters: sent once along with specific ones. @@ -1063,6 +1085,12 @@ FilterContainer.prototype.retrieveSpecificSelectors = function( } } + if ( injectedHideFilters.length !== 0 ) { + injectedCSS.push( + `${injectedHideFilters.join(',\n')}\n{display:none!important;}` + ); + } + // Important: always clear used registers before leaving. specificSet.clear(); proceduralSet.clear(); @@ -1077,10 +1105,11 @@ FilterContainer.prototype.retrieveSpecificSelectors = function( runAt: 'document_start', }; - if ( injectedHideFilters.length !== 0 ) { - out.injectedHideFilters = injectedHideFilters.join(',\n'); - details.code = out.injectedHideFilters + '\n{display:none!important;}'; - if ( options.dontInject !== true ) { + // Inject all declarative-based filters as a single stylesheet. + if ( injectedCSS.length !== 0 ) { + out.injectedCSS = injectedCSS.join('\n\n'); + details.code = out.injectedCSS; + if ( request.tabId !== undefined ) { vAPI.tabs.insertCSS(request.tabId, details); } } @@ -1091,7 +1120,7 @@ FilterContainer.prototype.retrieveSpecificSelectors = function( cacheEntry.retrieve('net', networkFilters); if ( networkFilters.length !== 0 ) { details.code = networkFilters.join('\n') + '\n{display:none!important;}'; - if ( options.dontInject !== true ) { + if ( request.tabId !== undefined ) { vAPI.tabs.insertCSS(request.tabId, details); } } @@ -1125,7 +1154,6 @@ FilterContainer.prototype.benchmark = async function() { const options = { noCosmeticFiltering: false, noGenericCosmeticFiltering: false, - dontInject: true, }; let count = 0; const t0 = self.performance.now(); diff --git a/src/js/messaging.js b/src/js/messaging.js index 74c80d25a..a3568948e 100644 --- a/src/js/messaging.js +++ b/src/js/messaging.js @@ -533,7 +533,7 @@ vAPI.messaging.listen({ const µb = µBlock; -const retrieveContentScriptParameters = function(sender, request) { +const retrieveContentScriptParameters = async function(sender, request) { if ( µb.readyToFilter !== true ) { return; } const { tabId, frameId } = sender; if ( tabId === undefined || frameId === undefined ) { return; } @@ -550,6 +550,7 @@ const retrieveContentScriptParameters = function(sender, request) { request.url = pageStore.getEffectiveFrameURL(sender); } + const loggerEnabled = µb.logger.enabled; const noCosmeticFiltering = pageStore.noCosmeticFiltering === true; const response = { @@ -568,7 +569,7 @@ const retrieveContentScriptParameters = function(sender, request) { request.url ); response.noGenericCosmeticFiltering = genericHide === 2; - if ( genericHide !== 0 && µb.logger.enabled ) { + if ( loggerEnabled && genericHide !== 0 ) { µBlock.filteringContext .duplicate() .fromTabId(tabId) @@ -595,7 +596,7 @@ const retrieveContentScriptParameters = function(sender, request) { request.url ); response.noSpecificCosmeticFiltering = specificHide === 2; - if ( specificHide !== 0 && µb.logger.enabled ) { + if ( loggerEnabled && specificHide !== 0 ) { µBlock.filteringContext .duplicate() .fromTabId(tabId) @@ -620,6 +621,23 @@ const retrieveContentScriptParameters = function(sender, request) { response.specificCosmeticFilters = µb.cosmeticFilteringEngine.retrieveSpecificSelectors(request, response); + // The procedural filterer's code is loaded only when needed and must be + // present before returning response to caller. + if ( + Array.isArray(response.specificCosmeticFilters.proceduralFilters) || ( + loggerEnabled && + response.specificCosmeticFilters.exceptedFilters.length !== 0 + ) + ) { + await vAPI.tabs.executeScript(tabId, { + allFrames: false, + file: '/js/contentscript-extra.js', + frameId, + matchAboutBlank: true, + runAt: 'document_start', + }); + } + // https://github.com/uBlockOrigin/uBlock-issues/issues/688#issuecomment-748179731 // For non-network URIs, scriptlet injection is deferred to here. The // effective URL is available here in `request.url`. @@ -630,8 +648,18 @@ const retrieveContentScriptParameters = function(sender, request) { response.scriptlets = µb.scriptletFilteringEngine.retrieve(request); } - if ( µb.logger.enabled && response.noCosmeticFiltering !== true ) { - µb.logCosmeticFilters(tabId, frameId); + // https://github.com/NanoMeow/QuickReports/issues/6#issuecomment-414516623 + // Inject as early as possible to make the cosmetic logger code less + // sensitive to the removal of DOM nodes which may match injected + // cosmetic filters. + if ( loggerEnabled && response.noCosmeticFiltering !== true ) { + vAPI.tabs.executeScript(tabId, { + allFrames: false, + file: '/js/scriptlets/cosmetic-logger.js', + frameId, + matchAboutBlank: true, + runAt: 'document_start', + }); } return response; @@ -640,6 +668,13 @@ const retrieveContentScriptParameters = function(sender, request) { const onMessage = function(request, sender, callback) { // Async switch ( request.what ) { + case 'retrieveContentScriptParameters': + return retrieveContentScriptParameters( + sender, + request + ).then(response => { + callback(response); + }); default: break; } @@ -686,10 +721,6 @@ const onMessage = function(request, sender, callback) { } break; - case 'retrieveContentScriptParameters': - response = retrieveContentScriptParameters(sender, request); - break; - case 'retrieveGenericCosmeticSelectors': request.tabId = sender.tabId; request.frameId = sender.frameId; @@ -728,6 +759,25 @@ const onMessage = function(request, sender, callback) { // Async switch ( request.what ) { + // The procedural filterer must be present in case the user wants to + // type-in custom filters. + case 'elementPickerArguments': + return vAPI.tabs.executeScript(sender.tabId, { + allFrames: false, + file: '/js/contentscript-extra.js', + frameId: sender.frameId, + matchAboutBlank: true, + runAt: 'document_start', + }).then(( ) => { + callback({ + target: µb.epickerArgs.target, + mouse: µb.epickerArgs.mouse, + zap: µb.epickerArgs.zap, + eprom: µb.epickerArgs.eprom, + pickerURL: vAPI.getURL(`/web_accessible_resources/epicker-ui.html?secret=${vAPI.warSecret()}`), + }); + µb.epickerArgs.target = ''; + }); default: break; } @@ -736,16 +786,6 @@ const onMessage = function(request, sender, callback) { let response; switch ( request.what ) { - case 'elementPickerArguments': - response = { - target: µb.epickerArgs.target, - mouse: µb.epickerArgs.mouse, - zap: µb.epickerArgs.zap, - eprom: µb.epickerArgs.eprom, - pickerURL: vAPI.getURL(`/web_accessible_resources/epicker-ui.html?secret=${vAPI.warSecret()}`), - }; - µb.epickerArgs.target = ''; - break; case 'elementPickerEprom': µb.epickerArgs.eprom = request; break; diff --git a/src/js/scriptlets/epicker.js b/src/js/scriptlets/epicker.js index 9f44c7047..4f84121a1 100644 --- a/src/js/scriptlets/epicker.js +++ b/src/js/scriptlets/epicker.js @@ -805,9 +805,8 @@ const filterToDOMInterface = (( ) => { } } if ( cssSelectors.size !== 0 ) { - vAPI.domFilterer.addCSSRule( - Array.from(cssSelectors), - vAPI.hideStyle, + vAPI.domFilterer.addCSS( + `${Array.from(cssSelectors).join('\n')}\n{${vAPI.hideStyle}}`, { mustInject: true } ); } diff --git a/src/js/ublock.js b/src/js/ublock.js index 9076f3da3..2a7d188ad 100644 --- a/src/js/ublock.js +++ b/src/js/ublock.js @@ -643,22 +643,6 @@ const matchBucket = function(url, hostname, bucket, start) { /******************************************************************************/ -// https://github.com/NanoMeow/QuickReports/issues/6#issuecomment-414516623 -// Inject as early as possible to make the cosmetic logger code less -// sensitive to the removal of DOM nodes which may match injected -// cosmetic filters. - -µBlock.logCosmeticFilters = function(tabId, frameId) { - vAPI.tabs.executeScript(tabId, { - file: '/js/scriptlets/cosmetic-logger.js', - frameId: frameId, - matchAboutBlank: true, - runAt: 'document_start', - }); -}; - -/******************************************************************************/ - µBlock.scriptlets = (function() { const pendingEntries = new Map();