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();