diff --git a/platform/mv3/extension/js/scripting-manager.js b/platform/mv3/extension/js/scripting-manager.js index ce0a24051..d37ee8231 100644 --- a/platform/mv3/extension/js/scripting-manager.js +++ b/platform/mv3/extension/js/scripting-manager.js @@ -105,14 +105,19 @@ const toRegisterable = (fname, entry) => { directive.excludeMatches = matchesFromHostnames(entry.excludeMatches); } directive.js = [ `/rulesets/js/${fname.slice(0,2)}/${fname.slice(2)}.js` ]; - directive.runAt = 'document_start'; + if ( (fidFromFileName(fname) & RUN_AT_BIT) !== 0 ) { + directive.runAt = 'document_end'; + } else { + directive.runAt = 'document_start'; + } if ( (fidFromFileName(fname) & MAIN_WORLD_BIT) !== 0 ) { directive.world = 'MAIN'; } return directive; }; -const MAIN_WORLD_BIT = 0b1; +const RUN_AT_BIT = 0b10; +const MAIN_WORLD_BIT = 0b01; /******************************************************************************/ diff --git a/platform/mv3/make-rulesets.js b/platform/mv3/make-rulesets.js index 99fb646bb..a0592c570 100644 --- a/platform/mv3/make-rulesets.js +++ b/platform/mv3/make-rulesets.js @@ -367,48 +367,53 @@ function addScriptingAPIResources(id, entry, prop, fid) { } } -const toCSSFileId = s => uidint32(s) & ~0b1; -const toJSFileId = s => uidint32(s) | 0b1; +const toCSSFileId = s => (uidint32(s) & ~0b11) | 0b00; +const toJSFileId = s => (uidint32(s) & ~0b11) | 0b01; +const toProceduralFileId = s => (uidint32(s) & ~0b11) | 0b10; + const pathFromFileName = fname => `${scriptletDir}/${fname.slice(0,2)}/${fname.slice(2)}.js`; /******************************************************************************/ -async function processCosmeticFilters(assetDetails, mapin) { - if ( mapin === undefined ) { return 0; } +const COSMETIC_FILES_PER_RULESET = 12; +const PROCEDURAL_FILES_PER_RULESET = 4; - // This groups together selectors which are used by the same hostname. - const optimized = (filters => { - if ( filters === undefined ) { return []; } - const merge = new Map(); - for ( const [ selector, details ] of filters ) { - const json = JSON.stringify(details); - let entries = merge.get(json); - if ( entries === undefined ) { - entries = new Set(); - merge.set(json, entries); - } - entries.add(selector); - } - const out = []; - for ( const [ json, entries ] of merge ) { - const details = JSON.parse(json); - details.selector = Array.from(entries).join(','); - out.push(details); - } - return out; - })(mapin); +// This merges selectors which are used by the same hostnames - // This creates a map of unique selectorset => all hostnames - // including/excluding the selectorset. This allows to avoid duplication - // of css content. - const cssContentMap = new Map(); - for ( const entry of optimized ) { - // ends-with 0 = css resource - const id = uidint32(entry.selector); - let details = cssContentMap.get(id); +function groupCosmeticByHostnames(mapin) { + if ( mapin === undefined ) { return []; } + const merged = new Map(); + for ( const [ selector, details ] of mapin ) { + const json = JSON.stringify(details); + let entries = merged.get(json); + if ( entries === undefined ) { + entries = new Set(); + merged.set(json, entries); + } + entries.add(selector); + } + const out = []; + for ( const [ json, entries ] of merged ) { + const details = JSON.parse(json); + details.selectors = Array.from(entries).sort(); + out.push(details); + } + return out; +} + +// This merges hostnames which have the same set of selectors. +// +// Also, we sort the hostnames to increase likelihood that selector with +// same hostnames will end up in same generated scriptlet. + +function groupCosmeticBySelectors(arrayin) { + const contentMap = new Map(); + for ( const entry of arrayin ) { + const id = uidint32(JSON.stringify(entry.selectors)); + let details = contentMap.get(id); if ( details === undefined ) { - details = { a: entry.selector }; - cssContentMap.set(id, details); + details = { a: entry.selectors }; + contentMap.set(id, details); } if ( entry.matches !== undefined ) { if ( details.y === undefined ) { @@ -427,8 +432,61 @@ async function processCosmeticFilters(assetDetails, mapin) { } } } + const hnSort = (a, b) => + a.split('.').reverse().join('.').localeCompare( + b.split('.').reverse().join('.') + ); + const out = Array.from(contentMap).map(a => [ + a[0], { + a: a[1].a, + y: a[1].y ? Array.from(a[1].y).sort(hnSort) : undefined, + n: a[1].n ? Array.from(a[1].n) : undefined, + } + ]).sort((a, b) => { + const ha = Array.isArray(a[1].y) ? a[1].y[0] : '*'; + const hb = Array.isArray(b[1].y) ? b[1].y[0] : '*'; + return hnSort(ha, hb); + }); + return out; +} - // We do not want more than 16 CSS files per subscription, so we will +const scriptletHostnameToIdMap = (hostnames, id, map) => { + for ( const hn of hostnames ) { + const existing = map.get(hn); + if ( existing === undefined ) { + map.set(hn, id); + } else if ( Array.isArray(existing) ) { + existing.push(id); + } else { + map.set(hn, [ existing, id ]); + } + } +}; + +const scriptletJsonReplacer = (k, v) => { + if ( k === 'n' ) { + if ( v === undefined || v.size === 0 ) { return; } + return Array.from(v); + } + if ( v instanceof Set || v instanceof Map ) { + if ( v.size === 0 ) { return; } + return Array.from(v); + } + return v; +}; + +/******************************************************************************/ + +async function processCosmeticFilters(assetDetails, mapin) { + if ( mapin === undefined ) { return 0; } + + const contentArray = groupCosmeticBySelectors( + groupCosmeticByHostnames(mapin) + ); + const contentPerFile = + Math.ceil(contentArray.length / COSMETIC_FILES_PER_RULESET); + + // We do not want more than n CSS files per subscription, so we will // group multiple unrelated selectors in the same file, and distinct // css declarations will be injected programmatically according to the // hostname of the current document. @@ -437,55 +495,33 @@ async function processCosmeticFilters(assetDetails, mapin) { // script and the decisions to activate the cosmetic filters will be // done at injection time according to the document's hostname. const originalScriptletMap = await loadAllSourceScriptlets(); - const contentPerFile = Math.ceil(cssContentMap.size / 16); - const cssContentArray = Array.from(cssContentMap); - - const jsonReplacer = (k, v) => { - if ( k === 'n' ) { - if ( v === undefined || v.size === 0 ) { return; } - return Array.from(v); - } - if ( v instanceof Set || v instanceof Map ) { - if ( v.size === 0 ) { return; } - return Array.from(v); - } - return v; - }; - - const toHostnamesMap = (hostnames, id, out) => { - for ( const hn of hostnames ) { - const existing = out.get(hn); - if ( existing === undefined ) { - out.set(hn, id); - } else if ( Array.isArray(existing) ) { - existing.push(id); - } else { - out.set(hn, [ existing, id ]); - } - } - }; - const generatedFiles = []; - for ( let i = 0; i < cssContentArray.length; i += contentPerFile ) { - const slice = cssContentArray.slice(i, i + contentPerFile); + for ( let i = 0; i < contentArray.length; i += contentPerFile ) { + const slice = contentArray.slice(i, i + contentPerFile); const argsMap = slice.map(entry => [ - entry[0], { a: entry[1].a, n: entry[1].n } + entry[0], + { + a: entry[1].a ? entry[1].a.join(',\n') : undefined, + n: entry[1].n + } ]); const hostnamesMap = new Map(); for ( const [ id, details ] of slice ) { if ( details.y === undefined ) { continue; } - toHostnamesMap(details.y, id, hostnamesMap); + scriptletHostnameToIdMap(details.y, id, hostnamesMap); } const patchedScriptlet = originalScriptletMap.get('css-specific') .replace( + '$rulesetId$', + assetDetails.id + ).replace( /\bself\.\$argsMap\$/m, - `${JSON.stringify(argsMap, jsonReplacer)}` + `${JSON.stringify(argsMap, scriptletJsonReplacer)}` ).replace( /\bself\.\$hostnamesMap\$/m, - `${JSON.stringify(hostnamesMap, jsonReplacer)}` + `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` ); - // ends-with 0 = css resource const fid = toCSSFileId(patchedScriptlet); if ( globalPatchedScriptletsSet.has(fid) === false ) { globalPatchedScriptletsSet.add(fid); @@ -510,12 +546,91 @@ async function processCosmeticFilters(assetDetails, mapin) { } if ( generatedFiles.length !== 0 ) { - log(`CSS-related distinct filters: ${cssContentArray.length} distinct combined selectors`); + log(`CSS-related distinct filters: ${contentArray.length} distinct combined selectors`); log(`CSS-related injectable files: ${generatedFiles.length}`); log(`\t${generatedFiles.join(', ')}`); } - return cssContentArray.length; + return contentArray.length; +} + +/******************************************************************************/ + +async function processProceduralCosmeticFilters(assetDetails, mapin) { + if ( mapin === undefined ) { return 0; } + + const contentArray = groupCosmeticBySelectors( + groupCosmeticByHostnames(mapin) + ); + const contentPerFile = + Math.ceil(contentArray.length / PROCEDURAL_FILES_PER_RULESET); + + // We do not want more than n CSS files per subscription, so we will + // group multiple unrelated selectors in the same file, and distinct + // css declarations will be injected programmatically according to the + // hostname of the current document. + // + // The cosmetic filters will be injected programmatically as content + // script and the decisions to activate the cosmetic filters will be + // done at injection time according to the document's hostname. + const originalScriptletMap = await loadAllSourceScriptlets(); + const generatedFiles = []; + + for ( let i = 0; i < contentArray.length; i += contentPerFile ) { + const slice = contentArray.slice(i, i + contentPerFile); + const argsMap = slice.map(entry => [ + entry[0], + { + a: entry[1].a ? entry[1].a.map(v => JSON.parse(v)) : undefined, + n: entry[1].n + } + ]); + const hostnamesMap = new Map(); + for ( const [ id, details ] of slice ) { + if ( details.y === undefined ) { continue; } + scriptletHostnameToIdMap(details.y, id, hostnamesMap); + } + const patchedScriptlet = originalScriptletMap.get('css-specific-procedural') + .replace( + '$rulesetId$', + assetDetails.id + ).replace( + /\bself\.\$argsMap\$/m, + `${JSON.stringify(argsMap, scriptletJsonReplacer)}` + ).replace( + /\bself\.\$hostnamesMap\$/m, + `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` + ); + const fid = toProceduralFileId(patchedScriptlet); + if ( globalPatchedScriptletsSet.has(fid) === false ) { + globalPatchedScriptletsSet.add(fid); + const fname = fnameFromFileId(fid); + writeFile(pathFromFileName(fname), patchedScriptlet, {}); + generatedFiles.push(fname); + } + for ( const entry of slice ) { + addScriptingAPIResources( + assetDetails.id, + { matches: entry[1].y }, + 'matches', + fid + ); + addScriptingAPIResources( + assetDetails.id, + { excludeMatches: entry[1].n }, + 'excludeMatches', + fid + ); + } + } + + if ( generatedFiles.length !== 0 ) { + log(`Pprocedural-related distinct filters: ${contentArray.length} distinct combined selectors`); + log(`Pprocedural-related injectable files: ${generatedFiles.length}`); + log(`\t${generatedFiles.join(', ')}`); + } + + return contentArray.length; } /******************************************************************************/ @@ -605,31 +720,6 @@ async function processScriptletFilters(assetDetails, mapin) { const generatedFiles = []; - const jsonReplacer = (k, v) => { - if ( k === 'n' ) { - if ( v.size === 0 ) { return; } - return Array.from(v); - } - if ( v instanceof Set || v instanceof Map ) { - if ( v.size === 0 ) { return; } - return Array.from(v); - } - return v; - }; - - const toHostnamesMap = (hostnames, hash, out) => { - for ( const hn of hostnames ) { - const existing = out.get(hn); - if ( existing === undefined ) { - out.set(hn, hash); - } else if ( Array.isArray(existing) ) { - existing.push(hash); - } else { - out.set(hn, [ existing, hash ]); - } - } - }; - for ( const [ token, argsDetails ] of scriptletDetails ) { const argsMap = Array.from(argsDetails).map(entry => [ uidint32(entry[0]), @@ -637,15 +727,18 @@ async function processScriptletFilters(assetDetails, mapin) { ]); const hostnamesMap = new Map(); for ( const [ argsHash, details ] of argsDetails ) { - toHostnamesMap(details.y, uidint32(argsHash), hostnamesMap); + scriptletHostnameToIdMap(details.y, uidint32(argsHash), hostnamesMap); } const patchedScriptlet = originalScriptletMap.get(token) .replace( + '$rulesetId$', + assetDetails.id + ).replace( /\bself\.\$argsMap\$/m, - `${JSON.stringify(argsMap, jsonReplacer)}` + `${JSON.stringify(argsMap, scriptletJsonReplacer)}` ).replace( /\bself\.\$hostnamesMap\$/m, - `${JSON.stringify(hostnamesMap, jsonReplacer)}` + `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` ); // ends-with 1 = scriptlet resource const fid = toJSFileId(patchedScriptlet); @@ -684,6 +777,95 @@ async function processScriptletFilters(assetDetails, mapin) { /******************************************************************************/ +const rulesetFromURLS = async function(assetDetails) { + log('============================'); + log(`Listset for '${assetDetails.id}':`); + + const text = await fetchAsset(assetDetails); + + const results = await dnrRulesetFromRawLists( + [ { name: assetDetails.id, text } ], + { env } + ); + + const netStats = await processNetworkFilters( + assetDetails, + results.network + ); + + // Split cosmetic filters into two groups: declarative and procedural + const declarativeCosmetic = new Map(); + const proceduralCosmetic = new Map(); + const rejectedCosmetic = []; + if ( results.cosmetic ) { + for ( const [ selector, details ] of results.cosmetic ) { + if ( details.rejected ) { + rejectedCosmetic.push(selector); + continue; + } + if ( selector.startsWith('{') === false ) { + declarativeCosmetic.set(selector, details); + continue; + } + const parsed = JSON.parse(selector); + parsed.raw = undefined; + proceduralCosmetic.set(JSON.stringify(parsed), details); + } + } + const cosmeticStats = await processCosmeticFilters( + assetDetails, + declarativeCosmetic + ); + const proceduralStats = await processProceduralCosmeticFilters( + assetDetails, + proceduralCosmetic + ); + if ( rejectedCosmetic.length !== 0 ) { + log(`Rejected cosmetic filters: ${rejectedCosmetic.length}`); + log(rejectedCosmetic.map(line => `\t${line}`).join('\n')); + } + + const scriptletStats = await processScriptletFilters( + assetDetails, + results.scriptlet + ); + + rulesetDetails.push({ + id: assetDetails.id, + name: assetDetails.name, + enabled: assetDetails.enabled, + lang: assetDetails.lang, + homeURL: assetDetails.homeURL, + filters: { + total: results.network.filterCount, + accepted: results.network.acceptedFilterCount, + rejected: results.network.rejectedFilterCount, + }, + rules: { + total: netStats.total, + accepted: netStats.accepted, + discarded: netStats.discarded, + rejected: netStats.rejected, + regexes: netStats.regexes, + }, + css: { + specific: cosmeticStats, + procedural: proceduralStats, + }, + scriptlets: { + total: scriptletStats, + }, + }); + + ruleResources.push({ + id: assetDetails.id, + enabled: assetDetails.enabled, + path: `/rulesets/${assetDetails.id}.json` + }); +}; + +/******************************************************************************/ + async function main() { // Get manifest content @@ -706,65 +888,6 @@ async function main() { } log(`Version: ${version}`); - const rulesetFromURLS = async function(assetDetails) { - log('============================'); - log(`Listset for '${assetDetails.id}':`); - - const text = await fetchAsset(assetDetails); - - const results = await dnrRulesetFromRawLists( - [ { name: assetDetails.id, text } ], - { env } - ); - - const netStats = await processNetworkFilters( - assetDetails, - results.network - ); - - const cosmeticStats = await processCosmeticFilters( - assetDetails, - results.cosmetic - ); - - const scriptletStats = await processScriptletFilters( - assetDetails, - results.scriptlet - ); - - rulesetDetails.push({ - id: assetDetails.id, - name: assetDetails.name, - enabled: assetDetails.enabled, - lang: assetDetails.lang, - homeURL: assetDetails.homeURL, - filters: { - total: results.network.filterCount, - accepted: results.network.acceptedFilterCount, - rejected: results.network.rejectedFilterCount, - }, - rules: { - total: netStats.total, - accepted: netStats.accepted, - discarded: netStats.discarded, - rejected: netStats.rejected, - regexes: netStats.regexes, - }, - css: { - specific: cosmeticStats, - }, - scriptlets: { - total: scriptletStats, - }, - }); - - ruleResources.push({ - id: assetDetails.id, - enabled: assetDetails.enabled, - path: `/rulesets/${assetDetails.id}.json` - }); - }; - // Get assets.json content const assets = await fs.readFile( `./assets.json`, diff --git a/platform/mv3/scriptlets/abort-current-script.js b/platform/mv3/scriptlets/abort-current-script.js index e96242a67..3c14594e3 100644 --- a/platform/mv3/scriptlets/abort-current-script.js +++ b/platform/mv3/scriptlets/abort-current-script.js @@ -41,6 +41,14 @@ /******************************************************************************/ +// $rulesetId$ + +const argsMap = new Map(self.$argsMap$); + +const hostnamesMap = new Map(self.$hostnamesMap$); + +/******************************************************************************/ + // Issues to mind before changing anything: // https://github.com/uBlockOrigin/uBlock-issues/issues/2154 @@ -145,10 +153,6 @@ const scriptlet = ( /******************************************************************************/ -const argsMap = new Map(self.$argsMap$); - -const hostnamesMap = new Map(self.$hostnamesMap$); - let hn; try { hn = document.location.hostname; } catch(ex) { } while ( hn ) { @@ -161,13 +165,22 @@ while ( hn ) { try { scriptlet(...details.a); } catch(ex) {} } } + if ( hn === '*' ) { break; } const pos = hn.indexOf('.'); - if ( pos === -1 ) { break; } - hn = hn.slice(pos + 1); + if ( pos !== -1 ) { + hn = hn.slice(pos + 1); + } else { + hn = '*'; + } } /******************************************************************************/ +argsMap.clear(); +hostnamesMap.clear(); + +/******************************************************************************/ + })(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/abort-on-property-read.js b/platform/mv3/scriptlets/abort-on-property-read.js index 0753fffe6..4896fe508 100644 --- a/platform/mv3/scriptlets/abort-on-property-read.js +++ b/platform/mv3/scriptlets/abort-on-property-read.js @@ -39,6 +39,14 @@ /******************************************************************************/ +// $rulesetId$ + +const argsMap = new Map(self.$argsMap$); + +const hostnamesMap = new Map(self.$hostnamesMap$); + +/******************************************************************************/ + const ObjGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const ObjDefineProperty = Object.defineProperty; @@ -103,10 +111,6 @@ const scriptlet = ( /******************************************************************************/ -const argsMap = new Map(self.$argsMap$); - -const hostnamesMap = new Map(self.$hostnamesMap$); - let hn; try { hn = document.location.hostname; } catch(ex) { } while ( hn ) { @@ -119,13 +123,22 @@ while ( hn ) { try { scriptlet(...details.a); } catch(ex) {} } } + if ( hn === '*' ) { break; } const pos = hn.indexOf('.'); - if ( pos === -1 ) { break; } - hn = hn.slice(pos + 1); + if ( pos !== -1 ) { + hn = hn.slice(pos + 1); + } else { + hn = '*'; + } } /******************************************************************************/ +argsMap.clear(); +hostnamesMap.clear(); + +/******************************************************************************/ + })(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/css-specific-procedural.js b/platform/mv3/scriptlets/css-specific-procedural.js new file mode 100644 index 000000000..acf1c3482 --- /dev/null +++ b/platform/mv3/scriptlets/css-specific-procedural.js @@ -0,0 +1,683 @@ +/******************************************************************************* + + 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 +*/ + +/* jshint esversion:11 */ + +'use strict'; + +/******************************************************************************/ + +/// name css-specific-procedural + +/******************************************************************************/ + +// Important! +// Isolate from global scope +(function() { + +/******************************************************************************/ + +// $rulesetId$ + +const argsMap = new Map(self.$argsMap$); + +const hostnamesMap = new Map(self.$hostnamesMap$); + +/******************************************************************************/ + +const addStylesheet = text => { + try { + const sheet = new CSSStyleSheet(); + sheet.replace(`@layer{${text}}`); + document.adoptedStyleSheets = [ + ...document.adoptedStyleSheets, + sheet + ]; + } catch(ex) { + } +}; + +const nonVisualElements = { + script: true, + style: true, +}; + +// 'P' stands for 'Procedural' + +class PSelectorTask { + begin() { + } + end() { + } +} + +class PSelectorHasTextTask extends PSelectorTask { + constructor(task) { + super(); + 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); + } + } +} + +class PSelectorIfTask extends PSelectorTask { + constructor(task) { + super(); + this.pselector = new PSelector(task[1]); + } + transpose(node, output) { + if ( this.pselector.test(node) === this.target ) { + output.push(node); + } + } +} +PSelectorIfTask.prototype.target = true; + +class PSelectorIfNotTask extends PSelectorIfTask { +} +PSelectorIfNotTask.prototype.target = false; + +class PSelectorMatchesCSSTask extends PSelectorTask { + constructor(task) { + super(); + this.name = task[1].name; + this.pseudo = task[1].pseudo ? `::${task[1].pseudo}` : null; + 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); + } + } +} + +class PSelectorMatchesMediaTask extends PSelectorTask { + constructor(task) { + super(); + this.mql = window.matchMedia(task[1]); + if ( this.mql.media === 'not all' ) { return; } + this.mql.addEventListener('change', ( ) => { + if ( typeof vAPI !== 'object' ) { return; } + if ( vAPI === null ) { return; } + const filterer = vAPI.domFilterer && vAPI.domFilterer.proceduralFilterer; + if ( filterer instanceof Object === false ) { return; } + filterer.onDOMChanged([ null ]); + }); + } + transpose(node, output) { + if ( this.mql.matches === false ) { return; } + output.push(node); + } +} + +class PSelectorMatchesPathTask extends PSelectorTask { + constructor(task) { + super(); + 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(self.location.pathname + self.location.search) ) { + output.push(node); + } + } +} + +class PSelectorMinTextLengthTask extends PSelectorTask { + constructor(task) { + super(); + this.min = task[1]; + } + transpose(node, output) { + if ( node.textContent.length >= this.min ) { + output.push(node); + } + } +} + +class PSelectorOthersTask extends PSelectorTask { + constructor() { + super(); + this.targets = new Set(); + } + begin() { + this.targets.clear(); + } + end(output) { + const toKeep = new Set(this.targets); + const toDiscard = new Set(); + const body = document.body; + let discard = null; + for ( let keep of this.targets ) { + while ( keep !== null && keep !== body ) { + toKeep.add(keep); + toDiscard.delete(keep); + discard = keep.previousElementSibling; + while ( discard !== null ) { + if ( + nonVisualElements[discard.localName] !== true && + toKeep.has(discard) === false + ) { + toDiscard.add(discard); + } + discard = discard.previousElementSibling; + } + discard = keep.nextElementSibling; + while ( discard !== null ) { + if ( + nonVisualElements[discard.localName] !== true && + toKeep.has(discard) === false + ) { + toDiscard.add(discard); + } + discard = discard.nextElementSibling; + } + keep = keep.parentElement; + } + } + for ( discard of toDiscard ) { + output.push(discard); + } + this.targets.clear(); + } + transpose(candidate) { + for ( const target of this.targets ) { + if ( target.contains(candidate) ) { return; } + if ( candidate.contains(target) ) { + this.targets.delete(target); + } + } + this.targets.add(candidate); + } +} + +// https://github.com/AdguardTeam/ExtendedCss/issues/31#issuecomment-302391277 +// Prepend `:scope ` if needed. +class PSelectorSpathTask extends PSelectorTask { + constructor(task) { + super(); + 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); + } + } + // Helper method for other operators. + static qsa(node, selector) { + 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})${selector}` + ); + } +} + +class PSelectorUpwardTask extends PSelectorTask { + constructor(task) { + super(); + 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 = ''; + +class PSelectorWatchAttrs extends PSelectorTask { + constructor(task) { + super(); + 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); + } +} + +class PSelectorXpathTask extends PSelectorTask { + constructor(task) { + super(); + 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); + } + } + } +} + +class PSelector { + 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-media', PSelectorMatchesMediaTask ], + [ 'matches-path', PSelectorMatchesPathTask ], + [ 'min-text-length', PSelectorMinTextLengthTask ], + [ 'not', PSelectorIfNotTask ], + [ 'others', PSelectorOthersTask ], + [ 'spath', PSelectorSpathTask ], + [ 'upward', PSelectorUpwardTask ], + [ 'watch-attr', PSelectorWatchAttrs ], + [ 'xpath', PSelectorXpathTask ], + ]); + } + this.raw = o.raw; + this.selector = o.selector; + this.tasks = []; + const tasks = []; + if ( Array.isArray(o.tasks) === false ) { return; } + for ( const task of o.tasks ) { + const ctor = this.operatorToTaskMap.get(task[0]); + if ( ctor === undefined ) { return; } + tasks.push(new ctor(task)); + } + // Initialize only after all tasks have been successfully instantiated + this.tasks = tasks; + } + prime(input) { + const root = input || document; + if ( this.selector === '' ) { return [ root ]; } + let selector = this.selector; + if ( input !== document && /^ [>+~]/.test(this.selector) ) { + return Array.from(PSelectorSpathTask.qsa(input, this.selector)); + } + const elems = root.querySelectorAll(selector); + return Array.from(elems); + } + exec(input) { + let nodes = this.prime(input); + for ( const task of this.tasks ) { + if ( nodes.length === 0 ) { break; } + const transposed = []; + task.begin(); + for ( const node of nodes ) { + task.transpose(node, transposed); + } + task.end(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 = []; + task.begin(); + for ( const node of output ) { + task.transpose(node, transposed); + } + task.end(transposed); + output = transposed; + if ( output.length === 0 ) { break; } + } + if ( output.length !== 0 ) { return true; } + } + return false; + } +} +PSelector.prototype.operatorToTaskMap = undefined; + +class PSelectorRoot 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; + } + prime(input) { + try { + return super.prime(input); + } catch (ex) { + } + return []; + } +} + +/******************************************************************************/ + +class ProceduralFilterer { + constructor(selectors) { + this.selectors = []; + this.masterToken = this.randomToken(); + this.styleTokenMap = new Map(); + this.styledNodes = new Set(); + this.timer = undefined; + this.addSelectors(selectors); + } + + addSelectors() { + for ( const selector of selectors ) { + let style, styleToken; + if ( selector.action === undefined ) { + style = 'display:none!important;'; + } 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.push(pselector); + } + this.onDOMChanged(); + } + + commitNow() { + //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('uBOL: disabling %s', pselector.raw); + pselector.budget = -0x7FFFFFFF; + } + t0 = t1; + if ( nodes.length === 0 ) { continue; } + this.styleNodes(nodes, pselector.styleToken); + } + + this.unstyleNodes(toUnstyle); + } + + styleTokenFromStyle(style) { + if ( style === undefined ) { return; } + let styleToken = this.styleTokenMap.get(style); + if ( styleToken !== undefined ) { return styleToken; } + styleToken = this.randomToken(); + this.styleTokenMap.set(style, styleToken); + addStylesheet( + `[${this.masterToken}][${styleToken}]\n{${style}}\n`, + ); + 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); + } + } + + unstyleNodes(nodes) { + for ( const node of nodes ) { + if ( this.styledNodes.has(node) ) { continue; } + node.removeAttribute(this.masterToken); + } + } + + randomToken() { + const n = Math.random(); + return String.fromCharCode(n * 25 + 97) + + Math.floor( + (0.25 + n * 0.75) * Number.MAX_SAFE_INTEGER + ).toString(36).slice(-8); + } + + onDOMChanged() { + if ( this.timer !== undefined ) { return; } + this.timer = self.requestAnimationFrame(( ) => { + this.timer = undefined; + this.commitNow(); + }); + } +} + +/******************************************************************************/ + +let hn; +try { hn = document.location.hostname; } catch(ex) { } +const selectors = []; +while ( hn ) { + if ( hostnamesMap.has(hn) ) { + let argsHashes = hostnamesMap.get(hn); + if ( typeof argsHashes === 'number' ) { argsHashes = [ argsHashes ]; } + for ( const argsHash of argsHashes ) { + const details = argsMap.get(argsHash); + if ( details.n && details.n.includes(hn) ) { continue; } + selectors.push(...details.a); + } + } + if ( hn === '*' ) { break; } + const pos = hn.indexOf('.'); + if ( pos !== -1 ) { + hn = hn.slice(pos + 1); + } else { + hn = '*'; + } +} + +const proceduralSelectors = []; +const styleSelectors = []; +for ( const selector of selectors ) { + if ( selector.cssable ) { + styleSelectors.push(selector); + } else { + proceduralSelectors.push(selector); + } +} + +/******************************************************************************/ + +// Declarative selectors + +if ( styleSelectors.length !== 0 ) { + const cssRuleFromProcedural = details => { + const { tasks, action } = details; + let mq; + if ( tasks !== undefined ) { + if ( tasks.length > 1 ) { return; } + if ( tasks[0][0] !== 'matches-media' ) { return; } + mq = tasks[0][1]; + } + let style; + if ( Array.isArray(action) ) { + if ( action[0] !== 'style' ) { return; } + style = action[1]; + } + if ( mq === undefined && style === undefined ) { return; } + if ( mq === undefined ) { + return `${details.selector}\n{${style}}`; + } + if ( style === undefined ) { + return `@media ${mq} {\n${details.selector}\n{display:none!important;}\n}`; + } + return `@media ${mq} {\n${details.selector}\n{${style}}\n}`; + }; + const sheetText = []; + for ( const selector of styleSelectors ) { + const ruleText = cssRuleFromProcedural(selector); + if ( ruleText === undefined ) { continue; } + sheetText.push(ruleText); + } + if ( sheetText.length !== 0 ) { + addStylesheet(sheetText.join('\n')); + } +} + +/******************************************************************************/ + +// Procedural selectors + +if ( proceduralSelectors.length !== 0 ) { + const filterer = new ProceduralFilterer(proceduralSelectors); + const observer = new MutationObserver(mutations => { + let domChanged = false; + for ( let i = 0; i < mutations.length && !domChanged; i++ ) { + const mutation = mutations[i]; + for ( const added of mutation.addedNodes ) { + if ( added.nodeType !== 1 ) { continue; } + domChanged = true; + } + for ( const removed of mutation.removedNodes ) { + if ( removed.nodeType !== 1 ) { continue; } + domChanged = true; + } + } + if ( domChanged === false ) { return; } + filterer.onDOMChanged(); + }); + observer.observe(document, { + childList: true, + subtree: true, + }); +} + +/******************************************************************************/ + +argsMap.clear(); +hostnamesMap.clear(); + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/platform/mv3/scriptlets/css-specific.js b/platform/mv3/scriptlets/css-specific.js index 44cea8ad0..2c6d16411 100644 --- a/platform/mv3/scriptlets/css-specific.js +++ b/platform/mv3/scriptlets/css-specific.js @@ -17,9 +17,6 @@ along with this program. If not, see {http://www.gnu.org/licenses/}. Home: https://github.com/gorhill/uBlock - - The scriptlets below are meant to be injected only into a - web page context. */ /* jshint esversion:11 */ @@ -38,10 +35,14 @@ /******************************************************************************/ +// $rulesetId$ + const argsMap = new Map(self.$argsMap$); const hostnamesMap = new Map(self.$hostnamesMap$); +/******************************************************************************/ + let hn; try { hn = document.location.hostname; } catch(ex) { } const styles = []; @@ -55,9 +56,13 @@ while ( hn ) { styles.push(details.a); } } + if ( hn === '*' ) { break; } const pos = hn.indexOf('.'); - if ( pos === -1 ) { break; } - hn = hn.slice(pos + 1); + if ( pos !== -1 ) { + hn = hn.slice(pos + 1); + } else { + hn = '*'; + } } if ( styles.length === 0 ) { return; } @@ -74,6 +79,11 @@ try { /******************************************************************************/ +argsMap.clear(); +hostnamesMap.clear(); + +/******************************************************************************/ + })(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/json-prune.js b/platform/mv3/scriptlets/json-prune.js index 3df397094..14d3fd347 100644 --- a/platform/mv3/scriptlets/json-prune.js +++ b/platform/mv3/scriptlets/json-prune.js @@ -38,6 +38,14 @@ /******************************************************************************/ +// $rulesetId$ + +const argsMap = new Map(self.$argsMap$); + +const hostnamesMap = new Map(self.$hostnamesMap$); + +/******************************************************************************/ + // https://github.com/uBlockOrigin/uBlock-issues/issues/1545 // - Add support for "remove everything if needle matches" case @@ -121,10 +129,6 @@ const scriptlet = ( /******************************************************************************/ -const argsMap = new Map(self.$argsMap$); - -const hostnamesMap = new Map(self.$hostnamesMap$); - let hn; try { hn = document.location.hostname; } catch(ex) { } while ( hn ) { @@ -137,13 +141,22 @@ while ( hn ) { try { scriptlet(...details.a); } catch(ex) {} } } + if ( hn === '*' ) { break; } const pos = hn.indexOf('.'); - if ( pos === -1 ) { break; } - hn = hn.slice(pos + 1); + if ( pos !== -1 ) { + hn = hn.slice(pos + 1); + } else { + hn = '*'; + } } /******************************************************************************/ +argsMap.clear(); +hostnamesMap.clear(); + +/******************************************************************************/ + })(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/set-constant.js b/platform/mv3/scriptlets/set-constant.js index 8790e2ad1..57a51bde2 100644 --- a/platform/mv3/scriptlets/set-constant.js +++ b/platform/mv3/scriptlets/set-constant.js @@ -39,6 +39,14 @@ /******************************************************************************/ +// $rulesetId$ + +const argsMap = new Map(self.$argsMap$); + +const hostnamesMap = new Map(self.$hostnamesMap$); + +/******************************************************************************/ + const scriptlet = ( chain = '', cValue = '' @@ -163,10 +171,6 @@ const scriptlet = ( /******************************************************************************/ -const argsMap = new Map(self.$argsMap$); - -const hostnamesMap = new Map(self.$hostnamesMap$); - let hn; try { hn = document.location.hostname; } catch(ex) { } while ( hn ) { @@ -179,13 +183,22 @@ while ( hn ) { try { scriptlet(...details.a); } catch(ex) {} } } + if ( hn === '*' ) { break; } const pos = hn.indexOf('.'); - if ( pos === -1 ) { break; } - hn = hn.slice(pos + 1); + if ( pos !== -1 ) { + hn = hn.slice(pos + 1); + } else { + hn = '*'; + } } /******************************************************************************/ +argsMap.clear(); +hostnamesMap.clear(); + +/******************************************************************************/ + })(); /******************************************************************************/ diff --git a/src/js/static-dnr-filtering.js b/src/js/static-dnr-filtering.js index 9aecf42b1..876970fcc 100644 --- a/src/js/static-dnr-filtering.js +++ b/src/js/static-dnr-filtering.js @@ -37,10 +37,11 @@ import { function addExtendedToDNR(context, parser) { if ( parser.category !== parser.CATStaticExtFilter ) { return false; } - if ( (parser.flavorBits & parser.BITFlavorUnsupported) !== 0 ) { return; } - // Scriptlet injection if ( (parser.flavorBits & parser.BITFlavorExtScriptlet) !== 0 ) { + if ( (parser.flavorBits & parser.BITFlavorUnsupported) !== 0 ) { + return; + } if ( parser.hasOptions() === false ) { return; } if ( context.scriptletFilters === undefined ) { context.scriptletFilters = new Map(); @@ -95,16 +96,24 @@ function addExtendedToDNR(context, parser) { // of same filter OR globally if there is no non-negated hostnames. for ( const { hn, not, bad } of parser.extOptions() ) { if ( bad ) { continue; } - if ( hn.endsWith('.*') ) { continue; } - const { compiled, exception } = parser.result; - if ( typeof compiled !== 'string' ) { continue; } - if ( compiled.startsWith('{') ) { continue; } + let { compiled, exception, raw } = parser.result; if ( exception ) { continue; } + let rejected; + if ( compiled === undefined ) { + rejected = `Invalid filter: ${hn}##${raw}`; + } else if ( hn.endsWith('.*') ) { + rejected = `Entity not supported: ${hn}##${raw}`; + } + if ( rejected ) { + compiled = rejected; + } let details = context.cosmeticFilters.get(compiled); if ( details === undefined ) { details = {}; + if ( rejected ) { details.rejected = true; } context.cosmeticFilters.set(compiled, details); } + if ( rejected ) { continue; } if ( not ) { if ( details.excludeMatches === undefined ) { details.excludeMatches = []; diff --git a/src/js/static-filtering-parser.js b/src/js/static-filtering-parser.js index fde3e7d92..fe14c7b06 100644 --- a/src/js/static-filtering-parser.js +++ b/src/js/static-filtering-parser.js @@ -1436,14 +1436,13 @@ Parser.prototype.SelectorCompiler = class { } out.compiled = this.compileSelector(raw); - if ( out.compiled === undefined ) { - console.log('Error:', raw); - return false; - } + if ( out.compiled === undefined ) { return false; } + if ( out.compiled instanceof Object ) { out.compiled.raw = raw; out.compiled = JSON.stringify(out.compiled); } + return true; }