diff --git a/platform/mv3/extension/js/background.js b/platform/mv3/extension/js/background.js index 4debbb2cb..547f573e2 100644 --- a/platform/mv3/extension/js/background.js +++ b/platform/mv3/extension/js/background.js @@ -42,7 +42,6 @@ import { } from './ruleset-manager.js'; import { - getInjectableCount, registerInjectables, } from './scripting-manager.js'; @@ -63,6 +62,8 @@ const rulesetConfig = { firstRun: false, }; +const UBOL_ORIGIN = runtime.getURL('').replace(/\/$/, ''); + /******************************************************************************/ function getCurrentVersion() { @@ -165,6 +166,9 @@ function onPermissionsRemoved(permissions) { /******************************************************************************/ function onMessage(request, sender, callback) { + + if ( sender.origin !== UBOL_ORIGIN ) { return; } + switch ( request.what ) { case 'applyRulesets': { @@ -214,7 +218,6 @@ function onMessage(request, sender, callback) { hasOmnipotence(), hasGreatPowers(request.origin), getEnabledRulesetsDetails(), - getInjectableCount(request.origin), ]).then(results => { callback({ level: results[0], @@ -222,7 +225,6 @@ function onMessage(request, sender, callback) { hasOmnipotence: results[1], hasGreatPowers: results[2], rulesetDetails: results[3], - injectableCount: results[4], }); }); return true; diff --git a/platform/mv3/extension/js/ruleset-manager.js b/platform/mv3/extension/js/ruleset-manager.js index 0ce93d751..4b5e692d2 100644 --- a/platform/mv3/extension/js/ruleset-manager.js +++ b/platform/mv3/extension/js/ruleset-manager.js @@ -101,7 +101,7 @@ async function updateRegexRules() { const toFetch = []; for ( const details of rulesetDetails ) { if ( details.rules.regexes === 0 ) { continue; } - toFetch.push(fetchJSON(`/rulesets/${details.id}.regexes`)); + toFetch.push(fetchJSON(`/rulesets/regex/${details.id}.regexes`)); } const regexRulesets = await Promise.all(toFetch); @@ -196,7 +196,7 @@ async function updateRemoveparamRules() { const toFetch = []; for ( const details of rulesetDetails ) { if ( details.rules.removeparams === 0 ) { continue; } - toFetch.push(fetchJSON(`/rulesets/${details.id}.removeparams`)); + toFetch.push(fetchJSON(`/rulesets/removeparam/${details.id}.removeparams`)); } const removeparamRulesets = await Promise.all(toFetch); diff --git a/platform/mv3/extension/js/scripting-manager.js b/platform/mv3/extension/js/scripting-manager.js index b42ae39d9..c23e16a60 100644 --- a/platform/mv3/extension/js/scripting-manager.js +++ b/platform/mv3/extension/js/scripting-manager.js @@ -25,7 +25,7 @@ /******************************************************************************/ -import { browser, dnr } from './ext.js'; +import { browser } from './ext.js'; import { fetchJSON } from './fetch.js'; import { getFilteringModeDetails } from './mode-manager.js'; import { getEnabledRulesetsDetails } from './ruleset-manager.js'; @@ -34,29 +34,69 @@ import * as ut from './utils.js'; /******************************************************************************/ -let scriptingDetailsPromise; +const resourceDetailPromises = new Map(); -function getScriptingDetails() { - if ( scriptingDetailsPromise !== undefined ) { - return scriptingDetailsPromise; - } - scriptingDetailsPromise = fetchJSON('/rulesets/scripting-details').then(entries => { +function getSpecificDetails() { + let promise = resourceDetailPromises.get('specific'); + if ( promise !== undefined ) { return promise; } + promise = fetchJSON('/rulesets/specific-details').then(entries => { const out = new Map(); for ( const entry of entries ) { out.set(entry[0], new Map(entry[1])); } return out; }); - return scriptingDetailsPromise; + resourceDetailPromises.set('specific', promise); + return promise; +} + +function getDeclarativeDetails() { + let promise = resourceDetailPromises.get('declarative'); + if ( promise !== undefined ) { return promise; } + promise = fetchJSON('/rulesets/declarative-details').then( + entries => new Map(entries) + ); + resourceDetailPromises.set('declarative', promise); + return promise; +} + +function getProceduralDetails() { + let promise = resourceDetailPromises.get('procedural'); + if ( promise !== undefined ) { return promise; } + promise = fetchJSON('/rulesets/procedural-details').then( + entries => new Map(entries) + ); + resourceDetailPromises.set('procedural', promise); + return promise; +} + +function getScriptletDetails() { + let promise = resourceDetailPromises.get('scriptlet'); + if ( promise !== undefined ) { return promise; } + promise = fetchJSON('/rulesets/scriptlet-details').then( + entries => new Map(entries) + ); + resourceDetailPromises.set('scriptlet', promise); + return promise; +} + +function getGenericDetails() { + let promise = resourceDetailPromises.get('generic'); + if ( promise !== undefined ) { return promise; } + promise = fetchJSON('/rulesets/generic-details').then( + entries => new Map(entries) + ); + resourceDetailPromises.set('generic', promise); + return promise; } /******************************************************************************/ // Important: We need to sort the arrays for fast comparison -const arrayEq = (a = [], b = []) => { +const arrayEq = (a = [], b = [], sort = true) => { const alen = a.length; if ( alen !== b.length ) { return false; } - a.sort(); b.sort(); + if ( sort ) { a.sort(); b.sort(); } for ( let i = 0; i < alen; i++ ) { if ( a[i] !== b[i] ) { return false; } } @@ -65,81 +105,41 @@ const arrayEq = (a = [], b = []) => { /******************************************************************************/ -const toRegisterableScript = (context, fname, hostnames) => { - if ( context.before.has(fname) ) { - return toUpdatableScript(context, fname, hostnames); - } - const matches = hostnames - ? ut.matchesFromHostnames(hostnames) - : [ '' ]; - const excludeMatches = matches.length === 1 && matches[0] === '' - ? ut.matchesFromHostnames(context.filteringModeDetails.none) - : []; - const runAt = (ut.fidFromFileName(fname) & RUN_AT_END_BIT) !== 0 - ? 'document_end' - : 'document_start'; - const directive = { - id: fname, - allFrames: true, - matches, - excludeMatches, - js: [ `/rulesets/js/${fname.slice(0,2)}/${fname.slice(2)}.js` ], - runAt, - }; - if ( (ut.fidFromFileName(fname) & MAIN_WORLD_BIT) !== 0 ) { - directive.world = 'MAIN'; - } - context.toAdd.push(directive); -}; +// The extensions API does not always return exactly what we fed it, so we +// need to normalize some entries to be sure we properly detect changes when +// comparing registered entries vs. entries to register. -const toUpdatableScript = (context, fname, hostnames) => { - const registered = context.before.get(fname); - context.before.delete(fname); // Important! - const directive = { id: fname }; - const matches = hostnames - ? ut.matchesFromHostnames(hostnames) - : [ '' ]; - if ( arrayEq(registered.matches, matches) === false ) { - directive.matches = matches; - } - const excludeMatches = matches.length === 1 && matches[0] === '' - ? ut.matchesFromHostnames(context.filteringModeDetails.none) - : []; - if ( arrayEq(registered.excludeMatches, excludeMatches) === false ) { - directive.excludeMatches = excludeMatches; - } - if ( directive.matches || directive.excludeMatches ) { - context.toUpdate.push(directive); +const normalizeRegisteredContentScripts = registered => { + for ( const entry of registered ) { + const { js } = entry; + for ( let i = 0; i < js.length; i++ ) { + const path = js[i]; + if ( path.startsWith('/') ) { continue; } + js[i] = `/${path}`; + } } + return registered; }; -const RUN_AT_END_BIT = 0b10; -const MAIN_WORLD_BIT = 0b01; - /******************************************************************************/ -async function registerGeneric(context, args) { - const { before } = context; - const registered = before.get('css-generic'); - before.delete('css-generic'); // Important! - - const { - filteringModeDetails, - rulesetsDetails, - } = args; +function registerGeneric(context, genericDetails) { + const { before, filteringModeDetails, rulesetsDetails } = context; + const excludeHostnames = []; const js = []; for ( const details of rulesetsDetails ) { + const hostnames = genericDetails.get(details.id); + if ( hostnames !== undefined ) { + excludeHostnames.push(...hostnames); + } if ( details.css.generic.count === 0 ) { continue; } - js.push(`/rulesets/js/${details.id}.generic.js`); + js.push(`/rulesets/scripting/generic/${details.id}.generic.js`); } - if ( js.length === 0 ) { - if ( registered !== undefined ) { - context.toRemove.push('css-generic'); - } - return; - } + if ( js.length === 0 ) { return; } + + js.push('/js/scripting/css-generic.js'); const matches = []; const excludeMatches = []; @@ -147,17 +147,23 @@ async function registerGeneric(context, args) { excludeMatches.push(...ut.matchesFromHostnames(filteringModeDetails.none)); excludeMatches.push(...ut.matchesFromHostnames(filteringModeDetails.network)); excludeMatches.push(...ut.matchesFromHostnames(filteringModeDetails.extendedSpecific)); + excludeMatches.push(...ut.matchesFromHostnames(excludeHostnames)); matches.push(''); } else { - matches.push(...ut.matchesFromHostnames(filteringModeDetails.extendedGeneric)); + matches.push( + ...ut.matchesFromHostnames( + ut.subtractHostnameIters( + Array.from(filteringModeDetails.extendedGeneric), + excludeHostnames + ) + ) + ); } - if ( matches.length === 0 ) { - if ( registered !== undefined ) { - context.toRemove.push('css-generic'); - } - return; - } + if ( matches.length === 0 ) { return; } + + const registered = before.get('css-generic'); + before.delete('css-generic'); // Important! // register if ( registered === undefined ) { @@ -173,7 +179,7 @@ async function registerGeneric(context, args) { // update const directive = { id: 'css-generic' }; - if ( arrayEq(registered.js, js) === false ) { + if ( arrayEq(registered.js, js, false) === false ) { directive.js = js; } if ( arrayEq(registered.matches, matches) === false ) { @@ -189,62 +195,258 @@ async function registerGeneric(context, args) { /******************************************************************************/ -async function getInjectableCount(origin) { - const url = ut.parsedURLromOrigin(origin); - if ( url === undefined ) { return 0; } +function registerProcedural(context, proceduralDetails) { + const { before, filteringModeDetails, rulesetsDetails } = context; - const [ - rulesetIds, - scriptingDetails, - ] = await Promise.all([ - dnr.getEnabledRulesets(), - getScriptingDetails(), - ]); - - let total = 0; - - for ( const rulesetId of rulesetIds ) { - const hostnamesToFidsMap = scriptingDetails.get(rulesetId); - if ( hostnamesToFidsMap === undefined ) { continue; } - let hn = url.hostname; - while ( hn !== '' ) { - const fids = hostnamesToFidsMap.get(hn); - if ( typeof fids === 'number' ) { - total += 1; - } else if ( Array.isArray(fids) ) { - total += fids.length; - } - hn = ut.toBroaderHostname(hn); + const js = []; + const hostnameMatches = []; + for ( const details of rulesetsDetails ) { + if ( details.css.procedural === 0 ) { continue; } + js.push(`/rulesets/scripting/procedural/${details.id}.procedural.js`); + if ( proceduralDetails.has(details.id) ) { + hostnameMatches.push(...proceduralDetails.get(details.id)); } } - return total; + if ( js.length === 0 ) { return; } + + js.push('/js/scripting/css-procedural.js'); + + const { + none, + network, + extendedSpecific, + extendedGeneric, + } = filteringModeDetails; + + const matches = []; + const excludeMatches = []; + if ( extendedSpecific.has('all-urls') || extendedGeneric.has('all-urls') ) { + excludeMatches.push(...ut.matchesFromHostnames(none)); + excludeMatches.push(...ut.matchesFromHostnames(network)); + matches.push(...ut.matchesFromHostnames(hostnameMatches)); + } else if ( extendedSpecific.size !== 0 || extendedGeneric.size !== 0 ) { + matches.push( + ...ut.matchesFromHostnames( + ut.intersectHostnameIters( + [ ...extendedSpecific, ...extendedGeneric ], + hostnameMatches + ) + ) + ); + } + + if ( matches.length === 0 ) { return; } + + const registered = before.get('css-procedural'); + before.delete('css-procedural'); // Important! + + // register + if ( registered === undefined ) { + context.toAdd.push({ + id: 'css-procedural', + js, + matches, + excludeMatches, + runAt: 'document_end', + }); + return; + } + + // update + const directive = { id: 'css-procedural' }; + if ( arrayEq(registered.js, js, false) === false ) { + directive.js = js; + } + if ( arrayEq(registered.matches, matches) === false ) { + directive.matches = matches; + } + if ( arrayEq(registered.excludeMatches, excludeMatches) === false ) { + directive.excludeMatches = excludeMatches; + } + if ( directive.js || directive.matches || directive.excludeMatches ) { + context.toUpdate.push(directive); + } } /******************************************************************************/ -function registerSpecific(args) { - const { - filteringModeDetails, - rulesetsDetails, - scriptingDetails, - } = args; +function registerDeclarative(context, declarativeDetails) { + const { before, filteringModeDetails, rulesetsDetails } = context; - // Combined both specific and generic sets + const js = []; + const hostnameMatches = []; + for ( const details of rulesetsDetails ) { + if ( details.css.declarative === 0 ) { continue; } + js.push(`/rulesets/scripting/declarative/${details.id}.declarative.js`); + if ( declarativeDetails.has(details.id) ) { + hostnameMatches.push(...declarativeDetails.get(details.id)); + } + } + + if ( js.length === 0 ) { return; } + + js.push('/js/scripting/css-declarative.js'); + + const { + none, + network, + extendedSpecific, + extendedGeneric, + } = filteringModeDetails; + + const matches = []; + const excludeMatches = []; + if ( extendedSpecific.has('all-urls') || extendedGeneric.has('all-urls') ) { + excludeMatches.push(...ut.matchesFromHostnames(none)); + excludeMatches.push(...ut.matchesFromHostnames(network)); + matches.push(...ut.matchesFromHostnames(hostnameMatches)); + } else if ( extendedSpecific.size !== 0 || extendedGeneric.size !== 0 ) { + matches.push( + ...ut.matchesFromHostnames( + ut.intersectHostnameIters( + [ ...extendedSpecific, ...extendedGeneric ], + hostnameMatches + ) + ) + ); + } + + if ( matches.length === 0 ) { return; } + + const registered = before.get('css-declarative'); + before.delete('css-declarative'); // Important! + + // register + if ( registered === undefined ) { + context.toAdd.push({ + id: 'css-declarative', + js, + matches, + excludeMatches, + runAt: 'document_start', + }); + return; + } + + // update + const directive = { id: 'css-declarative' }; + if ( arrayEq(registered.js, js, false) === false ) { + directive.js = js; + } + if ( arrayEq(registered.matches, matches) === false ) { + directive.matches = matches; + } + if ( arrayEq(registered.excludeMatches, excludeMatches) === false ) { + directive.excludeMatches = excludeMatches; + } + if ( directive.js || directive.matches || directive.excludeMatches ) { + context.toUpdate.push(directive); + } +} + +/******************************************************************************/ + +function registerScriptlet(context, scriptletDetails) { + const { before, filteringModeDetails, rulesetsDetails } = context; + + const hasBroadHostPermission = + filteringModeDetails.extendedSpecific.has('all-urls') || + filteringModeDetails.extendedGeneric.has('all-urls'); + + const permissionRevokedMatches = [ + ...ut.matchesFromHostnames(filteringModeDetails.none), + ...ut.matchesFromHostnames(filteringModeDetails.network), + ]; + const permissionGrantedHostnames = [ + ...filteringModeDetails.extendedSpecific, + ...filteringModeDetails.extendedGeneric, + ]; + + for ( const rulesetId of rulesetsDetails.map(v => v.id) ) { + const scriptletList = scriptletDetails.get(rulesetId); + if ( scriptletList === undefined ) { continue; } + + for ( const [ token, scriptletHostnames ] of scriptletList ) { + const id = `${rulesetId}.${token}`; + const registered = before.get(id); + + const matches = []; + const excludeMatches = []; + if ( hasBroadHostPermission ) { + excludeMatches.push(...permissionRevokedMatches); + matches.push(...ut.matchesFromHostnames(scriptletHostnames)); + } else if ( permissionGrantedHostnames.length !== 0 ) { + matches.push( + ...ut.matchesFromHostnames( + ut.intersectHostnameIters( + permissionGrantedHostnames, + scriptletHostnames + ) + ) + ); + } + if ( matches.length === 0 ) { continue; } + + before.delete(id); // Important! + + // register + if ( registered === undefined ) { + context.toAdd.push({ + id, + js: [ `/rulesets/scripting/scriptlet/${id}.js` ], + matches, + excludeMatches, + runAt: 'document_start', + world: 'MAIN', + }); + continue; + } + + // update + const directive = { id }; + if ( arrayEq(registered.matches, matches) === false ) { + directive.matches = matches; + } + if ( arrayEq(registered.excludeMatches, excludeMatches) === false ) { + directive.excludeMatches = excludeMatches; + } + if ( directive.matches || directive.excludeMatches ) { + context.toUpdate.push(directive); + } + } + } +} + +/******************************************************************************/ + +function registerSpecific(context, specificDetails) { + const { filteringModeDetails } = context; + + let toRegisterMap; if ( filteringModeDetails.extendedSpecific.has('all-urls') || filteringModeDetails.extendedGeneric.has('all-urls') ) { - return registerAllSpecific(args); + toRegisterMap = registerSpecificAll(context, specificDetails); + } else { + toRegisterMap = registerSpecificSome(context, specificDetails); } + for ( const [ fname, hostnames ] of toRegisterMap ) { + toRegisterableScript(context, fname, hostnames); + } +} + +function registerSpecificSome(context, specificDetails) { + const { filteringModeDetails, rulesetsDetails } = context; + const toRegisterMap = new Map(); + const targetHostnames = [ ...filteringModeDetails.extendedSpecific, ...filteringModeDetails.extendedGeneric, ]; - const toRegisterMap = new Map(); - const checkMatches = (hostnamesToFidsMap, hn) => { let fids = hostnamesToFidsMap.get(hn); if ( fids === undefined ) { return; } @@ -266,7 +468,7 @@ function registerSpecific(args) { }; for ( const rulesetDetails of rulesetsDetails ) { - const hostnamesToFidsMap = scriptingDetails.get(rulesetDetails.id); + const hostnamesToFidsMap = specificDetails.get(rulesetDetails.id); if ( hostnamesToFidsMap === undefined ) { continue; } for ( let hn of targetHostnames ) { while ( hn ) { @@ -279,21 +481,17 @@ function registerSpecific(args) { return toRegisterMap; } -function registerAllSpecific(args) { - const { - filteringModeDetails, - rulesetsDetails, - scriptingDetails, - } = args; - +function registerSpecificAll(context, specificDetails) { + const { filteringModeDetails, rulesetsDetails } = context; const toRegisterMap = new Map(); + const excludeSet = new Set([ ...filteringModeDetails.network, ...filteringModeDetails.none, ]); for ( const rulesetDetails of rulesetsDetails ) { - const hostnamesToFidsMap = scriptingDetails.get(rulesetDetails.id); + const hostnamesToFidsMap = specificDetails.get(rulesetDetails.id); if ( hostnamesToFidsMap === undefined ) { continue; } for ( let [ hn, fids ] of hostnamesToFidsMap ) { if ( excludeSet.has(hn) ) { continue; } @@ -319,6 +517,48 @@ function registerAllSpecific(args) { return toRegisterMap; } +const toRegisterableScript = (context, fname, hostnames) => { + if ( context.before.has(fname) ) { + return toUpdatableScript(context, fname, hostnames); + } + const matches = hostnames + ? ut.matchesFromHostnames(hostnames) + : [ '' ]; + const excludeMatches = matches.length === 1 && matches[0] === '' + ? ut.matchesFromHostnames(context.filteringModeDetails.none) + : []; + const directive = { + id: fname, + allFrames: true, + matches, + excludeMatches, + js: [ `/rulesets/scripting/specific/${fname.slice(-1)}/${fname.slice(0,-1)}.js` ], + runAt: 'document_start', + }; + context.toAdd.push(directive); +}; + +const toUpdatableScript = (context, fname, hostnames) => { + const registered = context.before.get(fname); + context.before.delete(fname); // Important! + const directive = { id: fname }; + const matches = hostnames + ? ut.matchesFromHostnames(hostnames) + : [ '' ]; + if ( arrayEq(registered.matches, matches) === false ) { + directive.matches = matches; + } + const excludeMatches = matches.length === 1 && matches[0] === '' + ? ut.matchesFromHostnames(context.filteringModeDetails.none) + : []; + if ( arrayEq(registered.excludeMatches, excludeMatches) === false ) { + directive.excludeMatches = excludeMatches; + } + if ( directive.matches || directive.excludeMatches ) { + context.toUpdate.push(directive); + } +}; + /******************************************************************************/ async function registerInjectables(origins) { @@ -329,37 +569,44 @@ async function registerInjectables(origins) { const [ filteringModeDetails, rulesetsDetails, - scriptingDetails, + declarativeDetails, + proceduralDetails, + scriptletDetails, + specificDetails, + genericDetails, registered, ] = await Promise.all([ getFilteringModeDetails(), getEnabledRulesetsDetails(), - getScriptingDetails(), + getDeclarativeDetails(), + getProceduralDetails(), + getScriptletDetails(), + getSpecificDetails(), + getGenericDetails(), browser.scripting.getRegisteredContentScripts(), ]); - - const before = new Map(registered.map(entry => [ entry.id, entry ])); + const before = new Map( + normalizeRegisteredContentScripts(registered).map( + entry => [ entry.id, entry ] + ) + ); const toAdd = [], toUpdate = [], toRemove = []; const promises = []; const context = { filteringModeDetails, + rulesetsDetails, before, toAdd, toUpdate, toRemove, }; - await registerGeneric(context, { filteringModeDetails, rulesetsDetails, }); + registerDeclarative(context, declarativeDetails); + registerProcedural(context, proceduralDetails); + registerScriptlet(context, scriptletDetails); + registerSpecific(context, specificDetails); + registerGeneric(context, genericDetails); - const toRegisterMap = registerSpecific({ - filteringModeDetails, - rulesetsDetails, - scriptingDetails, - }); - - for ( const [ fname, hostnames ] of toRegisterMap ) { - toRegisterableScript(context, fname, hostnames); - } toRemove.push(...Array.from(before.keys())); if ( toRemove.length !== 0 ) { @@ -391,6 +638,5 @@ async function registerInjectables(origins) { /******************************************************************************/ export { - getInjectableCount, registerInjectables }; diff --git a/platform/mv3/extension/js/scripting/css-declarative.js b/platform/mv3/extension/js/scripting/css-declarative.js new file mode 100644 index 000000000..0f7d65d3f --- /dev/null +++ b/platform/mv3/extension/js/scripting/css-declarative.js @@ -0,0 +1,115 @@ +/******************************************************************************* + + 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'; + +/******************************************************************************/ + +// Important! +// Isolate from global scope +(function uBOL_cssDeclarative() { + +/******************************************************************************/ + +const declarativeImports = self.declarativeImports || []; + +const lookupSelectors = (hn, out) => { + for ( const { argsList, hostnamesMap } of declarativeImports ) { + let argsIndices = hostnamesMap.get(hn); + if ( argsIndices === undefined ) { continue; } + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; + if ( details.n && details.n.includes(hn) ) { continue; } + out.push(...details.a.map(json => JSON.parse(json))); + } + } +}; + +let hn; +try { hn = document.location.hostname; } catch(ex) { } +const selectors = []; +while ( hn ) { + lookupSelectors(hn, selectors); + if ( hn === '*' ) { break; } + const pos = hn.indexOf('.'); + if ( pos !== -1 ) { + hn = hn.slice(pos + 1); + } else { + hn = '*'; + } +} + +declarativeImports.length = 0; + +/******************************************************************************/ + +if ( selectors.length === 0 ) { return; } + +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 selectors ) { + const ruleText = cssRuleFromProcedural(selector); + if ( ruleText === undefined ) { continue; } + sheetText.push(ruleText); +} + +if ( sheetText.length === 0 ) { return; } + +try { + const sheet = new CSSStyleSheet(); + sheet.replace(`@layer{${sheetText.join('\n')}}`); + document.adoptedStyleSheets = [ + ...document.adoptedStyleSheets, + sheet + ]; +} catch(ex) { +} + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/platform/mv3/extension/js/scripting/css-generic.js b/platform/mv3/extension/js/scripting/css-generic.js new file mode 100644 index 000000000..a7e089c79 --- /dev/null +++ b/platform/mv3/extension/js/scripting/css-generic.js @@ -0,0 +1,241 @@ +/******************************************************************************* + + 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'; + +/******************************************************************************/ + +// Important! +// Isolate from global scope +(function uBOL_cssGeneric() { + +const genericSelectorMap = self.genericSelectorMap || new Map(); +if ( genericSelectorMap.size === 0 ) { return; } + +self.genericSelectorMap = undefined; + +/******************************************************************************/ + +const maxSurveyTimeSlice = 4; +const maxSurveyNodeSlice = 64; +const styleSheetSelectors = []; +const stopAllRatio = 0.95; // To be investigated + +let surveyCount = 0; +let surveyMissCount = 0; +let styleSheetTimer; +let processTimer; +let domChangeTimer; +let lastDomChange = Date.now(); + +/******************************************************************************/ + +// https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ +const hashFromStr = (type, s) => { + const len = s.length; + const step = len + 7 >>> 3; + let hash = type; + for ( let i = 0; i < len; i += step ) { + hash = (hash << 5) - hash + s.charCodeAt(i) | 0; + } + return hash & 0x00FFFFFF; +}; + +/******************************************************************************/ + +// Extract all classes/ids: these will be passed to the cosmetic +// filtering engine, and in return we will obtain only the relevant +// CSS selectors. + +// https://github.com/gorhill/uBlock/issues/672 +// http://www.w3.org/TR/2014/REC-html5-20141028/infrastructure.html#space-separated-tokens +// http://jsperf.com/enumerate-classes/6 + +const uBOL_idFromNode = (node, out) => { + const raw = node.id; + if ( typeof raw !== 'string' || raw.length === 0 ) { return; } + out.push(hashFromStr(0x23 /* '#' */, raw.trim())); +}; + +// https://github.com/uBlockOrigin/uBlock-issues/discussions/2076 +// Performance: avoid using Element.classList +const uBOL_classesFromNode = (node, out) => { + const s = node.getAttribute('class'); + if ( typeof s !== 'string' ) { return; } + const len = s.length; + for ( let beg = 0, end = 0; beg < len; beg += 1 ) { + end = s.indexOf(' ', beg); + if ( end === beg ) { continue; } + if ( end === -1 ) { end = len; } + out.push(hashFromStr(0x2E /* '.' */, s.slice(beg, end))); + beg = end; + } +}; + +/******************************************************************************/ + +const pendingNodes = { + addedNodes: [], + nodeSet: new Set(), + add(node) { + this.addedNodes.push(node); + }, + next(out) { + for ( const added of this.addedNodes ) { + if ( this.nodeSet.has(added) ) { continue; } + if ( added.nodeType === 1 ) { + this.nodeSet.add(added); + } + if ( added.firstElementChild === null ) { continue; } + for ( const descendant of added.querySelectorAll('[id],[class]') ) { + this.nodeSet.add(descendant); + } + } + this.addedNodes.length = 0; + for ( const node of this.nodeSet ) { + this.nodeSet.delete(node); + out.push(node); + if ( out.length === maxSurveyNodeSlice ) { break; } + } + }, + hasNodes() { + return this.addedNodes.length !== 0 || this.nodeSet.size !== 0; + }, +}; + +/******************************************************************************/ + +const uBOL_processNodes = ( ) => { + const t0 = Date.now(); + const hashes = []; + const nodes = []; + const deadline = t0 + maxSurveyTimeSlice; + for (;;) { + pendingNodes.next(nodes); + if ( nodes.length === 0 ) { break; } + for ( const node of nodes ) { + uBOL_idFromNode(node, hashes); + uBOL_classesFromNode(node, hashes); + } + nodes.length = 0; + if ( performance.now() >= deadline ) { break; } + } + for ( const hash of hashes ) { + const selectorList = genericSelectorMap.get(hash); + if ( selectorList === undefined ) { continue; } + styleSheetSelectors.push(selectorList); + genericSelectorMap.delete(hash); + } + surveyCount += 1; + if ( styleSheetSelectors.length === 0 ) { + surveyMissCount += 1; + if ( + surveyCount >= 100 && + (surveyMissCount / surveyCount) >= stopAllRatio + ) { + stopAll(`too many misses in surveyor (${surveyMissCount}/${surveyCount})`); + } + return; + } + if ( styleSheetTimer !== undefined ) { return; } + styleSheetTimer = self.requestAnimationFrame(( ) => { + styleSheetTimer = undefined; + uBOL_injectStyleSheet(); + }); +}; + +/******************************************************************************/ + +const uBOL_processChanges = mutations => { + for ( let i = 0; i < mutations.length; i++ ) { + const mutation = mutations[i]; + for ( const added of mutation.addedNodes ) { + if ( added.nodeType !== 1 ) { continue; } + pendingNodes.add(added); + } + } + if ( pendingNodes.hasNodes() === false ) { return; } + lastDomChange = Date.now(); + if ( processTimer !== undefined ) { return; } + processTimer = self.setTimeout(( ) => { + processTimer = undefined; + uBOL_processNodes(); + }, 64); +}; + +/******************************************************************************/ + +const uBOL_injectStyleSheet = ( ) => { + try { + const sheet = new CSSStyleSheet(); + sheet.replace(`@layer{${styleSheetSelectors.join(',')}{display:none!important;}}`); + document.adoptedStyleSheets = [ + ...document.adoptedStyleSheets, + sheet + ]; + } catch(ex) { + } + styleSheetSelectors.length = 0; +}; + +/******************************************************************************/ + +pendingNodes.add(document); +uBOL_processNodes(); + +let domMutationObserver = new MutationObserver(uBOL_processChanges); +domMutationObserver.observe(document, { + childList: true, + subtree: true, +}); + +const needDomChangeObserver = ( ) => { + domChangeTimer = undefined; + if ( domMutationObserver === undefined ) { return; } + if ( (Date.now() - lastDomChange) > 20000 ) { + return stopAll('no more DOM changes'); + } + domChangeTimer = self.setTimeout(needDomChangeObserver, 20000); +}; + +needDomChangeObserver(); + +/******************************************************************************/ + +const stopAll = reason => { + if ( domChangeTimer !== undefined ) { + self.clearTimeout(domChangeTimer); + domChangeTimer = undefined; + } + domMutationObserver.disconnect(); + domMutationObserver.takeRecords(); + domMutationObserver = undefined; + genericSelectorMap.clear(); + console.info(`uBOL: Generic cosmetic filtering stopped because ${reason}`); +}; + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/platform/mv3/extension/js/scripting/css-procedural.js b/platform/mv3/extension/js/scripting/css-procedural.js new file mode 100644 index 000000000..83dd51a59 --- /dev/null +++ b/platform/mv3/extension/js/scripting/css-procedural.js @@ -0,0 +1,662 @@ +/******************************************************************************* + + 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'; + +/******************************************************************************/ + +// Important! +// Isolate from global scope +(function uBOL_cssProcedural() { + +/******************************************************************************/ + +let proceduralFilterer; + +/******************************************************************************/ + +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 PSelectorVoidTask extends PSelectorTask { + constructor(task) { + super(); + console.info(`uBO: :${task[0]}() operator does not exist`); + } + transpose() { + } +} + +/******************************************************************************/ + +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 PSelectorMatchesCSSAfterTask extends PSelectorMatchesCSSTask { + constructor(task) { + super(task); + this.pseudo = '::after'; + } +} + +class PSelectorMatchesCSSBeforeTask extends PSelectorMatchesCSSTask { + constructor(task) { + super(task); + this.pseudo = '::before'; + } +} + +/******************************************************************************/ + +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 ( proceduralFilterer instanceof Object === false ) { return; } + proceduralFilterer.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()}`; + } + } + transpose(node, output) { + const nodes = this.nth + ? PSelectorSpathTask.qsa(node, this.spath) + : node.querySelectorAll(this.spath); + 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() { + if ( proceduralFilterer instanceof Object ) { + proceduralFilterer.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) { + 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]) || PSelectorVoidTask; + tasks.push(new ctor(task)); + } + this.tasks = tasks; + } + prime(input) { + const root = input || document; + if ( this.selector === '' ) { return [ root ]; } + if ( input !== document && /^ [>+~]/.test(this.selector) ) { + return Array.from(PSelectorSpathTask.qsa(input, this.selector)); + } + 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 = []; + 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 = new Map([ + [ 'has', PSelectorIfTask ], + [ 'has-text', PSelectorHasTextTask ], + [ 'if', PSelectorIfTask ], + [ 'if-not', PSelectorIfNotTask ], + [ 'matches-css', PSelectorMatchesCSSTask ], + [ 'matches-css-after', PSelectorMatchesCSSAfterTask ], + [ 'matches-css-before', PSelectorMatchesCSSBeforeTask ], + [ 'matches-media', PSelectorMatchesMediaTask ], + [ 'matches-path', PSelectorMatchesPathTask ], + [ 'min-text-length', PSelectorMinTextLengthTask ], + [ 'not', PSelectorIfNotTask ], + [ 'others', PSelectorOthersTask ], + [ 'spath', PSelectorSpathTask ], + [ 'upward', PSelectorUpwardTask ], + [ 'watch-attr', PSelectorWatchAttrs ], + [ 'xpath', PSelectorXpathTask ], +]); + +/******************************************************************************/ + +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(); + } + + uBOL_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.uBOL_commitNow(); + }); + } +} + +/******************************************************************************/ + +const proceduralImports = self.proceduralImports || []; + +const lookupSelectors = (hn, out) => { + for ( const { argsList, hostnamesMap } of proceduralImports ) { + let argsIndices = hostnamesMap.get(hn); + if ( argsIndices === undefined ) { continue; } + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; + if ( details.n && details.n.includes(hn) ) { continue; } + out.push(...details.a.map(json => JSON.parse(json))); + } + } +}; + +let hn; +try { hn = document.location.hostname; } catch(ex) { } +const selectors = []; +while ( hn ) { + lookupSelectors(hn, selectors); + if ( hn === '*' ) { break; } + const pos = hn.indexOf('.'); + if ( pos !== -1 ) { + hn = hn.slice(pos + 1); + } else { + hn = '*'; + } +} + +proceduralImports.length = 0; + +/******************************************************************************/ + +if ( selectors.length === 0 ) { return; } + +proceduralFilterer = new ProceduralFilterer(selectors); + +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; + break; + } + if ( domChanged === false ) { + for ( const removed of mutation.removedNodes ) { + if ( removed.nodeType !== 1 ) { continue; } + domChanged = true; + break; + } + } + } + if ( domChanged === false ) { return; } + proceduralFilterer.onDOMChanged(); +}); + +observer.observe(document, { + childList: true, + subtree: true, +}); + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/platform/mv3/extension/js/utils.js b/platform/mv3/extension/js/utils.js index 43aa96e19..7d012ad69 100644 --- a/platform/mv3/extension/js/utils.js +++ b/platform/mv3/extension/js/utils.js @@ -42,22 +42,52 @@ const toBroaderHostname = hn => { /******************************************************************************/ -// Is a descendant hostname of b? +// Is hna descendant hostname of hnb? -const isDescendantHostname = (a, b) => { - if ( b === 'all-urls' ) { return true; } - if ( a.endsWith(b) === false ) { return false; } - if ( a === b ) { return false; } - return a.charCodeAt(a.length - b.length - 1) === 0x2E /* '.' */; +const isDescendantHostname = (hna, hnb) => { + if ( hnb === 'all-urls' ) { return true; } + if ( hna.endsWith(hnb) === false ) { return false; } + if ( hna === hnb ) { return false; } + return hna.charCodeAt(hna.length - hnb.length - 1) === 0x2E /* '.' */; }; -const isDescendantHostnameOfIter = (a, iter) => { - for ( const b of iter ) { - if ( isDescendantHostname(a, b) ) { return true; } +const isDescendantHostnameOfIter = (hna, iterb) => { + const setb = iterb instanceof Set ? iterb : new Set(iterb); + if ( setb.has('all-urls') || setb.has('*') ) { return true; } + let hn = hna; + while ( hn ) { + const pos = hn.indexOf('.'); + if ( pos === -1 ) { break; } + hn = hn.slice(pos + 1); + if ( setb.has(hn) ) { return true; } } return false; }; +const intersectHostnameIters = (itera, iterb) => { + const setb = iterb instanceof Set ? iterb : new Set(iterb); + if ( setb.has('all-urls') || setb.has('*') ) { return Array.from(itera); } + const out = []; + for ( const hna of itera ) { + if ( setb.has(hna) || isDescendantHostnameOfIter(hna, setb) ) { + out.push(hna); + } + } + return out; +}; + +const subtractHostnameIters = (itera, iterb) => { + const setb = iterb instanceof Set ? iterb : new Set(iterb); + if ( setb.has('all-urls') || setb.has('*') ) { return []; } + const out = []; + for ( const hna of itera ) { + if ( setb.has(hna) ) { continue; } + if ( isDescendantHostnameOfIter(hna, setb) ) { continue; } + out.push(hna); + } + return out; +}; + /******************************************************************************/ const matchesFromHostnames = hostnames => { @@ -102,6 +132,8 @@ export { toBroaderHostname, isDescendantHostname, isDescendantHostnameOfIter, + intersectHostnameIters, + subtractHostnameIters, matchesFromHostnames, hostnamesFromMatches, fnameFromFileId, diff --git a/platform/mv3/make-rulesets.js b/platform/mv3/make-rulesets.js index 1e57022ca..629267fb1 100644 --- a/platform/mv3/make-rulesets.js +++ b/platform/mv3/make-rulesets.js @@ -54,7 +54,7 @@ const commandLineArgs = (( ) => { const outputDir = commandLineArgs.get('output') || '.'; const cacheDir = `${outputDir}/../mv3-data`; const rulesetDir = `${outputDir}/rulesets`; -const scriptletDir = `${rulesetDir}/js`; +const scriptletDir = `${rulesetDir}/scripting`; const env = [ 'chromium', 'mv3', @@ -148,7 +148,11 @@ const writeOps = []; const ruleResources = []; const rulesetDetails = []; -const scriptingDetails = new Map(); +const declarativeDetails = new Map(); +const proceduralDetails = new Map(); +const scriptletStats = new Map(); +const specificDetails = new Map(); +const genericDetails = new Map(); /******************************************************************************/ @@ -284,20 +288,20 @@ async function processNetworkFilters(assetDetails, network) { log(bad.map(rule => rule._error.map(v => `\t\t${v}`)).join('\n'), true); writeFile( - `${rulesetDir}/${assetDetails.id}.json`, + `${rulesetDir}/main/${assetDetails.id}.json`, `${JSON.stringify(plainGood, replacer)}\n` ); if ( regexes.length !== 0 ) { writeFile( - `${rulesetDir}/${assetDetails.id}.regexes.json`, + `${rulesetDir}/regex/${assetDetails.id}.regexes.json`, `${JSON.stringify(regexes, replacer)}\n` ); } if ( removeparamsGood.length !== 0 ) { writeFile( - `${rulesetDir}/${assetDetails.id}.removeparams.json`, + `${rulesetDir}/removeparam/${assetDetails.id}.removeparams.json`, `${JSON.stringify(removeparamsGood, replacer)}\n` ); } @@ -366,10 +370,10 @@ const globalPatchedScriptletsSet = new Set(); function addScriptingAPIResources(id, hostnames, fid) { if ( hostnames === undefined ) { return; } for ( const hn of hostnames ) { - let hostnamesToFidMap = scriptingDetails.get(id); + let hostnamesToFidMap = specificDetails.get(id); if ( hostnamesToFidMap === undefined ) { hostnamesToFidMap = new Map(); - scriptingDetails.set(id, hostnamesToFidMap); + specificDetails.set(id, hostnamesToFidMap); } let fids = hostnamesToFidMap.get(hn); if ( fids === undefined ) { @@ -383,11 +387,9 @@ function addScriptingAPIResources(id, hostnames, fid) { } } -const toIsolatedStartFileId = s => (uidint32(s) & ~0b11) | 0b00; -const toMainStartFileId = s => (uidint32(s) & ~0b11) | 0b01; -const toIsolatedEndFileId = s => (uidint32(s) & ~0b11) | 0b10; +const toCSSSpecific = s => (uidint32(s) & ~0b11) | 0b00; -const pathFromFileName = fname => `${scriptletDir}/${fname.slice(0,2)}/${fname.slice(2)}.js`; +const pathFromFileName = fname => `${fname.slice(-1)}/${fname.slice(0,-1)}.js`; /******************************************************************************/ @@ -411,18 +413,17 @@ async function processGenericCosmeticFilters(assetDetails, bucketsMap, exclusion '$rulesetId$', assetDetails.id ).replace( - /\bself\.\$excludeHostnameSet\$/m, - `${JSON.stringify(exclusions, scriptletJsonReplacer)}` - ).replace( - /\bself\.\$genericSelectorLists\$/m, + /\bself\.\$genericSelectorMap\$/m, `${JSON.stringify(selectorLists, scriptletJsonReplacer)}` ); writeFile( - `${scriptletDir}/${assetDetails.id}.generic.js`, + `${scriptletDir}/generic/${assetDetails.id}.generic.js`, patchedScriptlet ); + genericDetails.set(assetDetails.id, exclusions.sort()); + log(`CSS-generic: ${count} plain CSS selectors`); return out; @@ -434,10 +435,11 @@ const MAX_COSMETIC_FILTERS_PER_FILE = 256; // This merges selectors which are used by the same hostnames -function groupCosmeticByHostnames(mapin) { +function groupSelectorsByHostnames(mapin) { if ( mapin === undefined ) { return []; } const merged = new Map(); for ( const [ selector, details ] of mapin ) { + if ( details.rejected ) { continue; } const json = JSON.stringify(details); let entries = merged.get(json); if ( entries === undefined ) { @@ -460,7 +462,7 @@ function groupCosmeticByHostnames(mapin) { // Also, we sort the hostnames to increase likelihood that selector with // same hostnames will end up in same generated scriptlet. -function groupCosmeticBySelectors(arrayin) { +function groupHostnamesBySelectors(arrayin) { const contentMap = new Map(); for ( const entry of arrayin ) { const id = uidint32(JSON.stringify(entry.selectors)); @@ -527,11 +529,32 @@ const scriptletJsonReplacer = (k, v) => { /******************************************************************************/ +function argsMap2List(argsMap, hostnamesMap) { + const argsList = []; + const indexMap = new Map(); + for ( const [ id, details ] of argsMap ) { + indexMap.set(id, argsList.length); + argsList.push(details); + } + for ( const [ hn, ids ] of hostnamesMap ) { + if ( typeof ids === 'number' ) { + hostnamesMap.set(hn, indexMap.get(ids)); + continue; + } + for ( let i = 0; i < ids.length; i++ ) { + ids[i] = indexMap.get(ids[i]); + } + } + return argsList; +} + +/******************************************************************************/ + async function processCosmeticFilters(assetDetails, mapin) { if ( mapin === undefined ) { return 0; } - const contentArray = groupCosmeticBySelectors( - groupCosmeticByHostnames(mapin) + const contentArray = groupHostnamesBySelectors( + groupSelectorsByHostnames(mapin) ); // We do not want more than n CSS files per subscription, so we will @@ -559,22 +582,23 @@ async function processCosmeticFilters(assetDetails, mapin) { if ( details.y === undefined ) { continue; } scriptletHostnameToIdMap(details.y, id, hostnamesMap); } + const argsList = argsMap2List(argsMap, hostnamesMap); const patchedScriptlet = originalScriptletMap.get('css-specific') .replace( '$rulesetId$', assetDetails.id ).replace( - /\bself\.\$argsMap\$/m, - `${JSON.stringify(argsMap, scriptletJsonReplacer)}` + /\bself\.\$argsList\$/m, + `${JSON.stringify(argsList, scriptletJsonReplacer)}` ).replace( /\bself\.\$hostnamesMap\$/m, `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` ); - const fid = toIsolatedStartFileId(patchedScriptlet); + const fid = toCSSSpecific(patchedScriptlet); if ( globalPatchedScriptletsSet.has(fid) === false ) { globalPatchedScriptletsSet.add(fid); const fname = fnameFromFileId(fid); - writeFile(pathFromFileName(fname), patchedScriptlet); + writeFile(`${scriptletDir}/specific/${pathFromFileName(fname)}`, patchedScriptlet); generatedFiles.push(fname); } for ( const entry of slice ) { @@ -593,65 +617,137 @@ async function processCosmeticFilters(assetDetails, mapin) { /******************************************************************************/ -async function processProceduralCosmeticFilters(assetDetails, mapin) { +async function processDeclarativeCosmeticFilters(assetDetails, mapin) { if ( mapin === undefined ) { return 0; } + if ( mapin.size === 0 ) { return 0; } - const contentArray = groupCosmeticBySelectors( - groupCosmeticByHostnames(mapin) + // Distinguish declarative-compiled-as-procedural from actual procedural. + const declaratives = new Map(); + mapin.forEach((details, jsonSelector) => { + const selector = JSON.parse(jsonSelector); + if ( selector.cssable !== true ) { return; } + declaratives.set(jsonSelector, details); + }); + if ( declaratives.size === 0 ) { return 0; } + + const contentArray = groupHostnamesBySelectors( + groupSelectorsByHostnames(declaratives) ); - // 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 += MAX_COSMETIC_FILTERS_PER_FILE ) { - const slice = contentArray.slice(i, i + MAX_COSMETIC_FILTERS_PER_FILE); - 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 = toIsolatedEndFileId(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, entry[1].y, fid); + const argsMap = contentArray.map(entry => [ + entry[0], + { + a: entry[1].a, + n: entry[1].n, } + ]); + const hostnamesMap = new Map(); + for ( const [ id, details ] of contentArray ) { + if ( details.y === undefined ) { continue; } + scriptletHostnameToIdMap(details.y, id, hostnamesMap); } - if ( generatedFiles.length !== 0 ) { + const argsList = argsMap2List(argsMap, hostnamesMap); + const originalScriptletMap = await loadAllSourceScriptlets(); + const patchedScriptlet = originalScriptletMap.get('css-declarative') + .replace( + '$rulesetId$', + assetDetails.id + ).replace( + /\bself\.\$argsList\$/m, + `${JSON.stringify(argsList, scriptletJsonReplacer)}` + ).replace( + /\bself\.\$hostnamesMap\$/m, + `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` + ); + writeFile(`${scriptletDir}/declarative/${assetDetails.id}.declarative.js`, patchedScriptlet); + + { + const hostnames = new Set(); + for ( const entry of contentArray ) { + if ( Array.isArray(entry[1].y) === false ) { continue; } + for ( const hn of entry[1].y ) { + hostnames.add(hn); + } + } + if ( hostnames.has('*') ) { + hostnames.clear(); + hostnames.add('*'); + } + declarativeDetails.set(assetDetails.id, Array.from(hostnames).sort()); + } + + if ( contentArray.length !== 0 ) { + log(`Declarative-related distinct filters: ${contentArray.length} distinct combined selectors`); + } + + return contentArray.length; +} + +/******************************************************************************/ + +async function processProceduralCosmeticFilters(assetDetails, mapin) { + if ( mapin === undefined ) { return 0; } + if ( mapin.size === 0 ) { return 0; } + + // Distinguish declarative-compiled-as-procedural from actual procedural. + const procedurals = new Map(); + mapin.forEach((details, jsonSelector) => { + const selector = JSON.parse(jsonSelector); + if ( selector.cssable ) { return; } + procedurals.set(jsonSelector, details); + }); + if ( procedurals.size === 0 ) { return 0; } + + const contentArray = groupHostnamesBySelectors( + groupSelectorsByHostnames(procedurals) + ); + + const argsMap = contentArray.map(entry => [ + entry[0], + { + a: entry[1].a, + n: entry[1].n, + } + ]); + const hostnamesMap = new Map(); + for ( const [ id, details ] of contentArray ) { + if ( details.y === undefined ) { continue; } + scriptletHostnameToIdMap(details.y, id, hostnamesMap); + } + + const argsList = argsMap2List(argsMap, hostnamesMap); + const originalScriptletMap = await loadAllSourceScriptlets(); + const patchedScriptlet = originalScriptletMap.get('css-procedural') + .replace( + '$rulesetId$', + assetDetails.id + ).replace( + /\bself\.\$argsList\$/m, + `${JSON.stringify(argsList, scriptletJsonReplacer)}` + ).replace( + /\bself\.\$hostnamesMap\$/m, + `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` + ); + writeFile(`${scriptletDir}/procedural/${assetDetails.id}.procedural.js`, patchedScriptlet); + + { + const hostnames = new Set(); + for ( const entry of contentArray ) { + if ( Array.isArray(entry[1].y) === false ) { continue; } + for ( const hn of entry[1].y ) { + hostnames.add(hn); + } + } + if ( hostnames.has('*') ) { + hostnames.clear(); + hostnames.add('*'); + } + proceduralDetails.set(assetDetails.id, Array.from(hostnames).sort()); + } + + if ( contentArray.length !== 0 ) { log(`Procedural-related distinct filters: ${contentArray.length} distinct combined selectors`); - log(`Procedural-related injectable files: ${generatedFiles.length}`); - log(`\t${generatedFiles.join(', ')}`); } return contentArray.length; @@ -753,28 +849,34 @@ async function processScriptletFilters(assetDetails, mapin) { for ( const [ argsHash, details ] of argsDetails ) { scriptletHostnameToIdMap(details.y, uidint32(argsHash), hostnamesMap); } + + const argsList = argsMap2List(argsMap, hostnamesMap); const patchedScriptlet = originalScriptletMap.get(token) .replace( '$rulesetId$', assetDetails.id ).replace( - /\bself\.\$argsMap\$/m, - `${JSON.stringify(argsMap, scriptletJsonReplacer)}` + /\bself\.\$argsList\$/m, + `${JSON.stringify(argsList, scriptletJsonReplacer)}` ).replace( /\bself\.\$hostnamesMap\$/m, `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` ); - // ends-with 1 = scriptlet resource - const fid = toMainStartFileId(patchedScriptlet); - if ( globalPatchedScriptletsSet.has(fid) === false ) { - globalPatchedScriptletsSet.add(fid); - const fname = fnameFromFileId(fid); - writeFile(pathFromFileName(fname), patchedScriptlet); - generatedFiles.push(fname); + const fname = `${assetDetails.id}.${token}.js`; + const fpath = `${scriptletDir}/scriptlet/${fname}`; + writeFile(fpath, patchedScriptlet); + generatedFiles.push(fname); + + const hostnameMatches = new Set(hostnamesMap.keys()); + if ( hostnameMatches.has('*') ) { + hostnameMatches.clear(); + hostnameMatches.add('*'); } - for ( const details of argsDetails.values() ) { - addScriptingAPIResources(assetDetails.id, details.y, fid); + let rulesetScriptlets = scriptletStats.get(assetDetails.id); + if ( rulesetScriptlets === undefined ) { + scriptletStats.set(assetDetails.id, rulesetScriptlets = []); } + rulesetScriptlets.push([ token, Array.from(hostnameMatches).sort() ]); } if ( generatedFiles.length !== 0 ) { @@ -790,15 +892,18 @@ async function processScriptletFilters(assetDetails, mapin) { /******************************************************************************/ -const rulesetFromURLS = async function(assetDetails) { +async function rulesetFromURLs(assetDetails) { log('============================'); log(`Listset for '${assetDetails.id}':`); - const text = await fetchAsset(assetDetails); - if ( text === '' ) { return; } + if ( assetDetails.text === undefined ) { + const text = await fetchAsset(assetDetails); + if ( text === '' ) { return; } + assetDetails.text = text; + } const results = await dnrRulesetFromRawLists( - [ { name: assetDetails.id, text } ], + [ { name: assetDetails.id, text: assetDetails.text } ], { env } ); @@ -826,6 +931,11 @@ const rulesetFromURLS = async function(assetDetails) { proceduralCosmetic.set(JSON.stringify(parsed), details); } } + if ( rejectedCosmetic.length !== 0 ) { + log(`Rejected cosmetic filters: ${rejectedCosmetic.length}`); + log(rejectedCosmetic.map(line => `\t${line}`).join('\n'), true); + } + const genericCosmeticStats = await processGenericCosmeticFilters( assetDetails, results.genericCosmetic, @@ -835,15 +945,14 @@ const rulesetFromURLS = async function(assetDetails) { assetDetails, declarativeCosmetic ); + const declarativeStats = await processDeclarativeCosmeticFilters( + assetDetails, + proceduralCosmetic + ); 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 @@ -871,6 +980,7 @@ const rulesetFromURLS = async function(assetDetails) { css: { generic: genericCosmeticStats, specific: specificCosmeticStats, + declarative: declarativeStats, procedural: proceduralStats, }, scriptlets: { @@ -881,9 +991,9 @@ const rulesetFromURLS = async function(assetDetails) { ruleResources.push({ id: assetDetails.id, enabled: assetDetails.enabled, - path: `/rulesets/${assetDetails.id}.json` + path: `/rulesets/main/${assetDetails.id}.json` }); -}; +} /******************************************************************************/ @@ -925,12 +1035,12 @@ async function main() { 'https://ublockorigin.pages.dev/filters/resource-abuse.txt', 'https://ublockorigin.pages.dev/filters/unbreak.txt', 'https://ublockorigin.pages.dev/filters/quick-fixes.txt', - 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/ubol-filters.txt', + 'https://ublockorigin.pages.dev/filters/ubol-filters.txt', 'https://secure.fanboy.co.nz/easylist.txt', 'https://secure.fanboy.co.nz/easyprivacy.txt', 'https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&showintro=1&mimetype=plaintext', ]; - await rulesetFromURLS({ + await rulesetFromURLs({ id: 'default', name: 'Ads, trackers, miners, and more' , enabled: true, @@ -967,7 +1077,7 @@ async function main() { } const id = ids[0]; const asset = assets[id]; - await rulesetFromURLS({ + await rulesetFromURLs({ id: id.toLowerCase(), lang: asset.lang, name: asset.title, @@ -986,7 +1096,7 @@ async function main() { const contentURL = Array.isArray(asset.contentURL) ? asset.contentURL[0] : asset.contentURL; - await rulesetFromURLS({ + await rulesetFromURLs({ id: id.toLowerCase(), name: asset.title, enabled: false, @@ -996,14 +1106,14 @@ async function main() { } // Handpicked rulesets from abroad - await rulesetFromURLS({ + await rulesetFromURLs({ id: 'cname-trackers', name: 'AdGuard CNAME-cloaked trackers', enabled: true, urls: [ 'https://raw.githubusercontent.com/AdguardTeam/cname-trackers/master/combined_disguised_trackers.txt' ], homeURL: 'https://github.com/AdguardTeam/cname-trackers#cname-cloaked-trackers', }); - await rulesetFromURLS({ + await rulesetFromURLs({ id: 'stevenblack-hosts', name: 'Steven Black\'s hosts file', enabled: false, @@ -1018,15 +1128,35 @@ async function main() { // We sort the hostnames for convenience/performance in the extension's // script manager -- the scripting API does a sort() internally. - for ( const [ rulesetId, hostnamesToFidsMap ] of scriptingDetails ) { - scriptingDetails.set( + for ( const [ rulesetId, hostnamesToFidsMap ] of specificDetails ) { + specificDetails.set( rulesetId, Array.from(hostnamesToFidsMap).sort() ); } writeFile( - `${rulesetDir}/scripting-details.json`, - `${JSON.stringify(scriptingDetails, jsonSetMapReplacer)}\n` + `${rulesetDir}/specific-details.json`, + `${JSON.stringify(specificDetails, jsonSetMapReplacer)}\n` + ); + + writeFile( + `${rulesetDir}/declarative-details.json`, + `${JSON.stringify(declarativeDetails, jsonSetMapReplacer, 1)}\n` + ); + + writeFile( + `${rulesetDir}/procedural-details.json`, + `${JSON.stringify(proceduralDetails, jsonSetMapReplacer, 1)}\n` + ); + + writeFile( + `${rulesetDir}/scriptlet-details.json`, + `${JSON.stringify(scriptletStats, jsonSetMapReplacer, 1)}\n` + ); + + writeFile( + `${rulesetDir}/generic-details.json`, + `${JSON.stringify(genericDetails, jsonSetMapReplacer, 1)}\n` ); await Promise.all(writeOps); diff --git a/platform/mv3/scriptlets/abort-current-script.js b/platform/mv3/scriptlets/abort-current-script.js index b1b4e3691..d7ae23ff7 100644 --- a/platform/mv3/scriptlets/abort-current-script.js +++ b/platform/mv3/scriptlets/abort-current-script.js @@ -43,7 +43,7 @@ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); @@ -157,10 +157,10 @@ let hn; try { hn = document.location.hostname; } catch(ex) { } 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); + let argsIndices = hostnamesMap.get(hn); + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } try { scriptlet(...details.a); } catch(ex) {} } @@ -174,9 +174,7 @@ while ( hn ) { } } -/******************************************************************************/ - -argsMap.clear(); +argsList.length = 0; hostnamesMap.clear(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/abort-on-property-read.js b/platform/mv3/scriptlets/abort-on-property-read.js index cdccd9f4d..5a8092288 100644 --- a/platform/mv3/scriptlets/abort-on-property-read.js +++ b/platform/mv3/scriptlets/abort-on-property-read.js @@ -41,7 +41,7 @@ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); @@ -115,10 +115,10 @@ let hn; try { hn = document.location.hostname; } catch(ex) { } 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); + let argsIndices = hostnamesMap.get(hn); + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } try { scriptlet(...details.a); } catch(ex) {} } @@ -132,9 +132,7 @@ while ( hn ) { } } -/******************************************************************************/ - -argsMap.clear(); +argsList.length = 0; hostnamesMap.clear(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/abort-on-property-write.js b/platform/mv3/scriptlets/abort-on-property-write.js index 2a9687cc4..6003a4b26 100644 --- a/platform/mv3/scriptlets/abort-on-property-write.js +++ b/platform/mv3/scriptlets/abort-on-property-write.js @@ -41,7 +41,7 @@ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); @@ -89,10 +89,10 @@ let hn; try { hn = document.location.hostname; } catch(ex) { } 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); + let argsIndices = hostnamesMap.get(hn); + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } try { scriptlet(...details.a); } catch(ex) {} } @@ -106,9 +106,7 @@ while ( hn ) { } } -/******************************************************************************/ - -argsMap.clear(); +argsList.length = 0; hostnamesMap.clear(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/css-declarative.js b/platform/mv3/scriptlets/css-declarative.js new file mode 100644 index 000000000..e5a342b5d --- /dev/null +++ b/platform/mv3/scriptlets/css-declarative.js @@ -0,0 +1,51 @@ +/******************************************************************************* + + 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-declarative + +/******************************************************************************/ + +// Important! +// Isolate from global scope +(function uBOL_cssDeclarativeImport() { + +/******************************************************************************/ + +// $rulesetId$ + +const argsList = self.$argsList$; + +const hostnamesMap = new Map(self.$hostnamesMap$); + +self.declarativeImports = self.declarativeImports || []; +self.declarativeImports.push({ argsList, hostnamesMap }); + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/platform/mv3/scriptlets/css-generic.js b/platform/mv3/scriptlets/css-generic.js index 7ded24d75..02010c5d6 100644 --- a/platform/mv3/scriptlets/css-generic.js +++ b/platform/mv3/scriptlets/css-generic.js @@ -31,258 +31,32 @@ // Important! // Isolate from global scope -(function uBOL_cssGeneric() { +(function uBOL_cssGenericImport() { /******************************************************************************/ // $rulesetId$ -{ - const excludeHostnameSet = new Set(self.$excludeHostnameSet$); +const toImport = self.$genericSelectorMap$; - let hn; - try { hn = document.location.hostname; } catch(ex) { } - while ( hn ) { - if ( excludeHostnameSet.has(hn) ) { return; } - const pos = hn.indexOf('.'); - if ( pos === -1 ) { break; } - hn = hn.slice(pos+1); - } - excludeHostnameSet.clear(); +const genericSelectorMap = self.genericSelectorMap || new Map(); + +if ( genericSelectorMap.size === 0 ) { + self.genericSelectorMap = new Map(toImport); + return; } -const genericSelectorLists = new Map(self.$genericSelectorLists$); +for ( const toImportEntry of toImport ) { + const existing = genericSelectorMap.get(toImportEntry[0]); + genericSelectorMap.set( + toImportEntry[0], + existing === undefined + ? toImportEntry[1] + : `${existing},${toImportEntry[1]}` + ); +} -/******************************************************************************/ - -const queriedHashes = new Set(); -const maxSurveyTimeSlice = 4; -const styleSheetSelectors = []; -const stopAllRatio = 0.95; // To be investigated - -let surveyCount = 0; -let surveyMissCount = 0; -let styleSheetTimer; -let processTimer; -let domChangeTimer; -let lastDomChange = Date.now(); - -/******************************************************************************/ - -// https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ -const hashFromStr = (type, s) => { - const len = s.length; - const step = len + 7 >>> 3; - let hash = type; - for ( let i = 0; i < len; i += step ) { - hash = (hash << 5) - hash + s.charCodeAt(i) | 0; - } - return hash & 0x00FFFFFF; -}; - -/******************************************************************************/ - -// Extract all classes/ids: these will be passed to the cosmetic -// filtering engine, and in return we will obtain only the relevant -// CSS selectors. - -// https://github.com/gorhill/uBlock/issues/672 -// http://www.w3.org/TR/2014/REC-html5-20141028/infrastructure.html#space-separated-tokens -// http://jsperf.com/enumerate-classes/6 - -const uBOL_idFromNode = (node, out) => { - const raw = node.id; - if ( typeof raw !== 'string' || raw.length === 0 ) { return; } - const s = raw.trim(); - const hash = hashFromStr(0x23 /* '#' */, s); - if ( queriedHashes.has(hash) ) { return; } - out.push(hash); - queriedHashes.add(hash); -}; - -// https://github.com/uBlockOrigin/uBlock-issues/discussions/2076 -// Performance: avoid using Element.classList -const uBOL_classesFromNode = (node, out) => { - const s = node.getAttribute('class'); - if ( typeof s !== 'string' ) { return; } - const len = s.length; - for ( let beg = 0, end = 0, token = ''; beg < len; beg += 1 ) { - end = s.indexOf(' ', beg); - if ( end === beg ) { continue; } - if ( end === -1 ) { end = len; } - token = s.slice(beg, end); - beg = end; - const hash = hashFromStr(0x2E /* '.' */, token); - if ( queriedHashes.has(hash) ) { continue; } - out.push(hash); - queriedHashes.add(hash); - } -}; - -/******************************************************************************/ - -const pendingNodes = { - nodeLists: [], - buffer: [ - null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, - ], - j: 0, - add(nodes) { - if ( nodes.length === 0 ) { return; } - this.nodeLists.push(nodes); - }, - next() { - if ( this.nodeLists.length === 0 ) { return 0; } - const maxSurveyBuffer = this.buffer.length; - const nodeLists = this.nodeLists; - let ib = 0; - do { - const nodeList = nodeLists[0]; - let j = this.j; - let n = j + maxSurveyBuffer - ib; - if ( n > nodeList.length ) { - n = nodeList.length; - } - for ( let i = j; i < n; i++ ) { - this.buffer[ib++] = nodeList[j++]; - } - if ( j !== nodeList.length ) { - this.j = j; - break; - } - this.j = 0; - this.nodeLists.shift(); - } while ( ib < maxSurveyBuffer && nodeLists.length !== 0 ); - return ib; - }, - hasNodes() { - return this.nodeLists.length !== 0; - }, -}; - -/******************************************************************************/ - -const uBOL_processNodes = ( ) => { - const t0 = Date.now(); - const hashes = []; - const nodes = pendingNodes.buffer; - const deadline = t0 + maxSurveyTimeSlice; - let processed = 0; - for (;;) { - const n = pendingNodes.next(); - if ( n === 0 ) { break; } - for ( let i = 0; i < n; i++ ) { - const node = nodes[i]; - nodes[i] = null; - uBOL_idFromNode(node, hashes); - uBOL_classesFromNode(node, hashes); - } - processed += n; - if ( performance.now() >= deadline ) { break; } - } - for ( const hash of hashes ) { - const selectorList = genericSelectorLists.get(hash); - if ( selectorList === undefined ) { continue; } - styleSheetSelectors.push(selectorList); - genericSelectorLists.delete(hash); - } - surveyCount += 1; - if ( styleSheetSelectors.length === 0 ) { - surveyMissCount += 1; - if ( - surveyCount >= 100 && - (surveyMissCount / surveyCount) >= stopAllRatio - ) { - stopAll('too many misses in surveyor'); - } - return; - } - if ( styleSheetTimer !== undefined ) { return; } - styleSheetTimer = self.requestAnimationFrame(( ) => { - styleSheetTimer = undefined; - uBOL_injectStyleSheet(); - }); -}; - -/******************************************************************************/ - -const uBOL_processChanges = mutations => { - for ( let i = 0; i < mutations.length; i++ ) { - const mutation = mutations[i]; - for ( const added of mutation.addedNodes ) { - if ( added.nodeType !== 1 ) { continue; } - pendingNodes.add([ added ]); - if ( added.firstElementChild === null ) { continue; } - pendingNodes.add(added.querySelectorAll('[id],[class]')); - } - } - if ( pendingNodes.hasNodes() === false ) { return; } - lastDomChange = Date.now(); - if ( processTimer !== undefined ) { return; } - processTimer = self.setTimeout(( ) => { - processTimer = undefined; - uBOL_processNodes(); - }, 64); -}; - -/******************************************************************************/ - -const uBOL_injectStyleSheet = ( ) => { - try { - const sheet = new CSSStyleSheet(); - sheet.replace(`@layer{${styleSheetSelectors.join(',')}{display:none!important;}}`); - document.adoptedStyleSheets = [ - ...document.adoptedStyleSheets, - sheet - ]; - } catch(ex) { - } - styleSheetSelectors.length = 0; -}; - -/******************************************************************************/ - -pendingNodes.add(document.querySelectorAll('[id],[class]')); -uBOL_processNodes(); - -let domMutationObserver = new MutationObserver(uBOL_processChanges); -domMutationObserver.observe(document, { - childList: true, - subtree: true, -}); - -const needDomChangeObserver = ( ) => { - domChangeTimer = undefined; - if ( domMutationObserver === undefined ) { return; } - if ( (Date.now() - lastDomChange) > 20000 ) { - return stopAll('no more DOM changes'); - } - domChangeTimer = self.setTimeout(needDomChangeObserver, 20000); -}; - -needDomChangeObserver(); - -/******************************************************************************/ - -const stopAll = reason => { - if ( domChangeTimer !== undefined ) { - self.clearTimeout(domChangeTimer); - domChangeTimer = undefined; - } - domMutationObserver.disconnect(); - domMutationObserver.takeRecords(); - domMutationObserver = undefined; - genericSelectorLists.clear(); - queriedHashes.clear(); - console.info(`uBOL: Generic cosmetic filtering stopped because ${reason}`); -}; +self.genericSelectorMap = genericSelectorMap; /******************************************************************************/ diff --git a/platform/mv3/scriptlets/css-procedural.js b/platform/mv3/scriptlets/css-procedural.js index 1fe9791d4..252b335a2 100644 --- a/platform/mv3/scriptlets/css-procedural.js +++ b/platform/mv3/scriptlets/css-procedural.js @@ -25,659 +25,24 @@ /******************************************************************************/ -/// name css-specific-procedural +/// name css-procedural /******************************************************************************/ // Important! // Isolate from global scope -(function uBOL_cssSpecificProcedural() { +(function uBOL_cssProceduralImport() { /******************************************************************************/ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); -/******************************************************************************/ - -let proceduralFilterer; - -/******************************************************************************/ - -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 PSelectorVoidTask extends PSelectorTask { - constructor(task) { - super(); - console.info(`uBO: :${task[0]}() operator does not exist`); - } - transpose() { - } -} - -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 PSelectorMatchesCSSAfterTask extends PSelectorMatchesCSSTask { - constructor(task) { - super(task); - this.pseudo = '::after'; - } -} - -class PSelectorMatchesCSSBeforeTask extends PSelectorMatchesCSSTask { - constructor(task) { - super(task); - this.pseudo = '::before'; - } -} - -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 ( proceduralFilterer instanceof Object === false ) { return; } - proceduralFilterer.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()}`; - } - } - transpose(node, output) { - const nodes = this.nth - ? PSelectorSpathTask.qsa(node, this.spath) - : node.querySelectorAll(this.spath); - 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() { - if ( proceduralFilterer instanceof Object ) { - proceduralFilterer.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-css-after', PSelectorMatchesCSSAfterTask ], - [ 'matches-css-before', PSelectorMatchesCSSBeforeTask ], - [ '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]) || PSelectorVoidTask; - 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 ]; } - if ( input !== document && /^ [>+~]/.test(this.selector) ) { - return Array.from(PSelectorSpathTask.qsa(input, this.selector)); - } - 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 = []; - 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(); - } - - uBOL_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.uBOL_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')); - } -} - -/******************************************************************************/ - -if ( proceduralSelectors.length !== 0 ) { - proceduralFilterer = 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; } - proceduralFilterer.onDOMChanged(); - }); - observer.observe(document, { - childList: true, - subtree: true, - }); -} - -/******************************************************************************/ - -argsMap.clear(); -hostnamesMap.clear(); +self.proceduralImports = self.proceduralImports || []; +self.proceduralImports.push({ argsList, hostnamesMap }); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/css-specific.js b/platform/mv3/scriptlets/css-specific.js index 14ded57e0..87341ccc9 100644 --- a/platform/mv3/scriptlets/css-specific.js +++ b/platform/mv3/scriptlets/css-specific.js @@ -37,7 +37,7 @@ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); @@ -48,10 +48,10 @@ try { hn = document.location.hostname; } catch(ex) { } const styles = []; 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); + let argsIndices = hostnamesMap.get(hn); + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } styles.push(details.a); } @@ -65,6 +65,9 @@ while ( hn ) { } } +argsList.length = 0; +hostnamesMap.clear(); + if ( styles.length === 0 ) { return; } try { @@ -79,11 +82,6 @@ try { /******************************************************************************/ -argsMap.clear(); -hostnamesMap.clear(); - -/******************************************************************************/ - })(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/json-prune.js b/platform/mv3/scriptlets/json-prune.js index dcd7dff23..253da2376 100644 --- a/platform/mv3/scriptlets/json-prune.js +++ b/platform/mv3/scriptlets/json-prune.js @@ -40,7 +40,7 @@ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); @@ -133,10 +133,10 @@ let hn; try { hn = document.location.hostname; } catch(ex) { } 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); + let argsIndices = hostnamesMap.get(hn); + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } try { scriptlet(...details.a); } catch(ex) {} } @@ -150,9 +150,7 @@ while ( hn ) { } } -/******************************************************************************/ - -argsMap.clear(); +argsList.length = 0; hostnamesMap.clear(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/no-addeventlistener-if.js b/platform/mv3/scriptlets/no-addeventlistener-if.js index bdf9b5a5e..a92857ae9 100644 --- a/platform/mv3/scriptlets/no-addeventlistener-if.js +++ b/platform/mv3/scriptlets/no-addeventlistener-if.js @@ -28,7 +28,7 @@ /******************************************************************************/ -/// name no-addEventListener-if +/// name no-addeventlistener-if /// alias noaelif /// alias aeld @@ -42,7 +42,7 @@ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); @@ -89,10 +89,10 @@ let hn; try { hn = document.location.hostname; } catch(ex) { } 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); + let argsIndices = hostnamesMap.get(hn); + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } try { scriptlet(...details.a); } catch(ex) {} } @@ -106,9 +106,7 @@ while ( hn ) { } } -/******************************************************************************/ - -argsMap.clear(); +argsList.length = 0; hostnamesMap.clear(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/no-setinterval-if.js b/platform/mv3/scriptlets/no-setinterval-if.js index ec2525ed2..5792ecc3f 100644 --- a/platform/mv3/scriptlets/no-setinterval-if.js +++ b/platform/mv3/scriptlets/no-setinterval-if.js @@ -28,7 +28,8 @@ /******************************************************************************/ -/// name no-setInterval-if +/// name no-setinterval-if +/// alias no-setInterval-if /// alias nosiif /******************************************************************************/ @@ -41,7 +42,7 @@ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); @@ -92,10 +93,10 @@ let hn; try { hn = document.location.hostname; } catch(ex) { } 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); + let argsIndices = hostnamesMap.get(hn); + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } try { scriptlet(...details.a); } catch(ex) {} } @@ -109,9 +110,7 @@ while ( hn ) { } } -/******************************************************************************/ - -argsMap.clear(); +argsList.length = 0; hostnamesMap.clear(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/no-settimeout-if.js b/platform/mv3/scriptlets/no-settimeout-if.js index 4a789d44d..03f93d119 100644 --- a/platform/mv3/scriptlets/no-settimeout-if.js +++ b/platform/mv3/scriptlets/no-settimeout-if.js @@ -28,7 +28,8 @@ /******************************************************************************/ -/// name no-setTimeout-if +/// name no-settimeout-if +/// alias no-setTimeout-if /// alias nostif /******************************************************************************/ @@ -41,7 +42,7 @@ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); @@ -92,10 +93,10 @@ let hn; try { hn = document.location.hostname; } catch(ex) { } 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); + let argsIndices = hostnamesMap.get(hn); + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } try { scriptlet(...details.a); } catch(ex) {} } @@ -109,9 +110,7 @@ while ( hn ) { } } -/******************************************************************************/ - -argsMap.clear(); +argsList.length = 0; hostnamesMap.clear(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/no-windowopen-if.js b/platform/mv3/scriptlets/no-windowopen-if.js index 051afebb0..9e65cc422 100644 --- a/platform/mv3/scriptlets/no-windowopen-if.js +++ b/platform/mv3/scriptlets/no-windowopen-if.js @@ -29,6 +29,7 @@ /******************************************************************************/ /// name no-windowOpen-if +/// alias no-windowopen-if /// alias nowoif /******************************************************************************/ @@ -41,7 +42,7 @@ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); @@ -129,10 +130,10 @@ let hn; try { hn = document.location.hostname; } catch(ex) { } 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); + let argsIndices = hostnamesMap.get(hn); + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } try { scriptlet(...details.a); } catch(ex) {} } @@ -146,9 +147,7 @@ while ( hn ) { } } -/******************************************************************************/ - -argsMap.clear(); +argsList.length = 0; hostnamesMap.clear(); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/set-constant.js b/platform/mv3/scriptlets/set-constant.js index 4510c3694..41ca3c471 100644 --- a/platform/mv3/scriptlets/set-constant.js +++ b/platform/mv3/scriptlets/set-constant.js @@ -41,7 +41,7 @@ // $rulesetId$ -const argsMap = new Map(self.$argsMap$); +const argsList = self.$argsList$; const hostnamesMap = new Map(self.$hostnamesMap$); @@ -175,10 +175,10 @@ let hn; try { hn = document.location.hostname; } catch(ex) { } 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); + let argsIndices = hostnamesMap.get(hn); + if ( typeof argsIndices === 'number' ) { argsIndices = [ argsIndices ]; } + for ( const argsIndex of argsIndices ) { + const details = argsList[argsIndex]; if ( details.n && details.n.includes(hn) ) { continue; } try { scriptlet(...details.a); } catch(ex) {} } @@ -192,9 +192,7 @@ while ( hn ) { } } -/******************************************************************************/ - -argsMap.clear(); +argsList.length = 0; hostnamesMap.clear(); /******************************************************************************/ diff --git a/tools/make-mv3.sh b/tools/make-mv3.sh index b542d1bb8..675d761e8 100755 --- a/tools/make-mv3.sh +++ b/tools/make-mv3.sh @@ -36,7 +36,7 @@ cp LICENSE.txt $DES/ echo "*** uBOLite.mv3: Copying mv3-specific files" cp platform/mv3/extension/*.html $DES/ cp platform/mv3/extension/css/* $DES/css/ -cp platform/mv3/extension/js/* $DES/js/ +cp -R platform/mv3/extension/js/* $DES/js/ cp platform/mv3/extension/img/* $DES/img/ cp -R platform/mv3/extension/_locales $DES/