uBlock/platform/mv3/extension/js/scripting-manager.js
Raymond Hill 98b011f64c
[mv3] Add support for explicit generichide filter option
`generichide` option is implicitly enforced on all sites unless an
exception overrides `generichide`. Though rare, sometimes a
`generichide` exception needs to be overridden so that generic
cosmetic filtering is made possible on a specific site.

This commit is to add support for restoring generic cosmetic
filtering on sites which were excluded through a `generichide`
exception.

Concretely, this is needed to ensure the test suite can properly
verify that generic cosmetic filtering is working when the
filtering mode is set to "complete":

||ublockorigin.github.io^$generichide,important
2025-03-18 17:15:35 -04:00

621 lines
20 KiB
JavaScript

/*******************************************************************************
uBlock Origin Lite - a comprehensive, MV3-compliant content blocker
Copyright (C) 2022-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
*/
import * as ut from './utils.js';
import { browser } from './ext.js';
import { fetchJSON } from './fetch.js';
import { getEnabledRulesetsDetails } from './ruleset-manager.js';
import { getFilteringModeDetails } from './mode-manager.js';
import { ubolLog } from './debug.js';
/******************************************************************************/
const resourceDetailPromises = new Map();
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 = [], sort = true) => {
const alen = a.length;
if ( alen !== b.length ) { return false; }
if ( sort ) { a.sort(); b.sort(); }
for ( let i = 0; i < alen; i++ ) {
if ( a[i] !== b[i] ) { return false; }
}
return true;
};
/******************************************************************************/
const normalizeMatches = matches => {
if ( matches.length <= 1 ) { return; }
if ( matches.includes('<all_urls>') === false ) { return; }
matches.length = 0;
matches.push('<all_urls>');
};
/******************************************************************************/
// 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 normalizeRegisteredContentScripts = registered => {
for ( const entry of registered ) {
const { css = [], js = [] } = entry;
for ( let i = 0; i < css.length; i++ ) {
const path = css[i];
if ( path.startsWith('/') ) { continue; }
css[i] = `/${path}`;
}
for ( let i = 0; i < js.length; i++ ) {
const path = js[i];
if ( path.startsWith('/') ) { continue; }
js[i] = `/${path}`;
}
}
return registered;
};
/******************************************************************************/
function registerHighGeneric(context, genericDetails) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const excludeHostnames = [];
const includeHostnames = [];
const css = [];
for ( const details of rulesetsDetails ) {
const hostnames = genericDetails.get(details.id);
if ( hostnames ) {
if ( hostnames.unhide ) {
excludeHostnames.push(...hostnames.unhide);
}
if ( hostnames.hide ) {
includeHostnames.push(...hostnames.hide);
}
}
const count = details.css?.generichigh || 0;
if ( count === 0 ) { continue; }
css.push(`/rulesets/scripting/generichigh/${details.id}.css`);
}
if ( css.length === 0 ) { return; }
const { none, basic, optimal, complete } = filteringModeDetails;
const matches = [];
const excludeMatches = [];
if ( complete.has('all-urls') ) {
excludeMatches.push(...ut.matchesFromHostnames(none));
excludeMatches.push(...ut.matchesFromHostnames(basic));
excludeMatches.push(...ut.matchesFromHostnames(optimal));
excludeMatches.push(...ut.matchesFromHostnames(excludeHostnames));
matches.push('<all_urls>');
} else {
matches.push(
...ut.matchesFromHostnames(
ut.subtractHostnameIters(
Array.from(complete),
excludeHostnames
)
)
);
}
if ( matches.length === 0 ) { return; }
const registered = before.get('css-generichigh');
before.delete('css-generichigh'); // Important!
// https://github.com/w3c/webextensions/issues/414#issuecomment-1623992885
// Once supported, add:
// cssOrigin: 'USER',
const directive = {
id: 'css-generichigh',
css,
allFrames: true,
matches,
excludeMatches,
runAt: 'document_end',
};
// register
if ( registered === undefined ) {
context.toAdd.push(directive);
return;
}
// update
if (
arrayEq(registered.css, css, false) === false ||
arrayEq(registered.matches, matches) === false ||
arrayEq(registered.excludeMatches, excludeMatches) === false
) {
context.toRemove.push('css-generichigh');
context.toAdd.push(directive);
}
}
/******************************************************************************/
function registerGeneric(context, genericDetails) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const excludedByFilter = [];
const includedByFilter = [];
const js = [];
for ( const details of rulesetsDetails ) {
const hostnames = genericDetails.get(details.id);
if ( hostnames ) {
if ( hostnames.unhide ) {
excludedByFilter.push(...hostnames.unhide);
}
if ( hostnames.hide ) {
includedByFilter.push(...hostnames.hide);
}
}
const count = details.css?.generic || 0;
if ( count === 0 ) { continue; }
js.push(`/rulesets/scripting/generic/${details.id}.js`);
}
if ( js.length === 0 ) { return; }
js.unshift('/js/scripting/isolated-api.js');
js.push('/js/scripting/css-generic.js');
const { none, basic, optimal, complete } = filteringModeDetails;
const includedByMode = [ ...complete ];
const excludedByMode = [ ...none, ...basic, ...optimal ];
if ( complete.has('all-urls') === false ) {
const matches = [
...ut.matchesFromHostnames(
ut.subtractHostnameIters(includedByMode, excludedByFilter)
),
...ut.matchesFromHostnames(
ut.intersectHostnameIters(includedByMode, includedByFilter)
),
];
if ( matches.length === 0 ) { return; }
const registered = before.get('css-generic-some');
before.delete('css-generic-some'); // Important!
const directive = {
id: 'css-generic-some',
js,
allFrames: true,
matches,
runAt: 'document_idle',
};
if ( registered === undefined ) { // register
context.toAdd.push(directive);
} else if ( // update
arrayEq(registered.js, js, false) === false ||
arrayEq(registered.matches, directive.matches) === false
) {
context.toRemove.push('css-generic-some');
context.toAdd.push(directive);
}
return;
}
const excludeMatches = [
...ut.matchesFromHostnames(excludedByMode),
...ut.matchesFromHostnames(excludedByFilter),
];
const registeredAll = before.get('css-generic-all');
before.delete('css-generic-all'); // Important!
const directiveAll = {
id: 'css-generic-all',
js,
allFrames: true,
matches: [ '<all_urls>' ],
excludeMatches,
runAt: 'document_idle',
};
if ( registeredAll === undefined ) { // register
context.toAdd.push(directiveAll);
} else if ( // update
arrayEq(registeredAll.js, js, false) === false ||
arrayEq(registeredAll.excludeMatches, directiveAll.excludeMatches) === false
) {
context.toRemove.push('css-generic-all');
context.toAdd.push(directiveAll);
}
const matches = [
...ut.matchesFromHostnames(
ut.subtractHostnameIters(includedByFilter, excludedByMode)
),
];
if ( matches.length === 0 ) { return; }
const registeredSome = before.get('css-generic-some');
before.delete('css-generic-some'); // Important!
const directiveSome = {
id: 'css-generic-some',
js,
allFrames: true,
matches,
runAt: 'document_idle',
};
if ( registeredSome === undefined ) { // register
context.toAdd.push(directiveSome);
} else if ( // update
arrayEq(registeredSome.js, js, false) === false ||
arrayEq(registeredSome.matches, directiveSome.matches) === false
) {
context.toRemove.push('css-generic-some');
context.toAdd.push(directiveSome);
}
}
/******************************************************************************/
function registerProcedural(context) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const js = [];
for ( const rulesetDetails of rulesetsDetails ) {
const count = rulesetDetails.css?.procedural || 0;
if ( count === 0 ) { continue; }
js.push(`/rulesets/scripting/procedural/${rulesetDetails.id}.js`);
}
if ( js.length === 0 ) { return; }
const { none, basic, optimal, complete } = filteringModeDetails;
const matches = [
...ut.matchesFromHostnames(optimal),
...ut.matchesFromHostnames(complete),
];
if ( matches.length === 0 ) { return; }
normalizeMatches(matches);
js.unshift('/js/scripting/isolated-api.js');
js.push('/js/scripting/css-procedural.js');
const excludeMatches = [];
if ( none.has('all-urls') === false ) {
excludeMatches.push(...ut.matchesFromHostnames(none));
}
if ( basic.has('all-urls') === false ) {
excludeMatches.push(...ut.matchesFromHostnames(basic));
}
const registered = before.get('css-procedural');
before.delete('css-procedural'); // Important!
const directive = {
id: 'css-procedural',
js,
allFrames: true,
matches,
excludeMatches,
runAt: 'document_start',
};
// register
if ( registered === undefined ) {
context.toAdd.push(directive);
return;
}
// update
if (
arrayEq(registered.js, js, false) === false ||
arrayEq(registered.matches, matches) === false ||
arrayEq(registered.excludeMatches, excludeMatches) === false
) {
context.toRemove.push('css-procedural');
context.toAdd.push(directive);
}
}
/******************************************************************************/
function registerDeclarative(context) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const js = [];
for ( const rulesetDetails of rulesetsDetails ) {
const count = rulesetDetails.css?.declarative || 0;
if ( count === 0 ) { continue; }
js.push(`/rulesets/scripting/declarative/${rulesetDetails.id}.js`);
}
if ( js.length === 0 ) { return; }
const { none, basic, optimal, complete } = filteringModeDetails;
const matches = [
...ut.matchesFromHostnames(optimal),
...ut.matchesFromHostnames(complete),
];
if ( matches.length === 0 ) { return; }
normalizeMatches(matches);
js.unshift('/js/scripting/isolated-api.js');
js.push('/js/scripting/css-declarative.js');
const excludeMatches = [];
if ( none.has('all-urls') === false ) {
excludeMatches.push(...ut.matchesFromHostnames(none));
}
if ( basic.has('all-urls') === false ) {
excludeMatches.push(...ut.matchesFromHostnames(basic));
}
const registered = before.get('css-declarative');
before.delete('css-declarative'); // Important!
const directive = {
id: 'css-declarative',
js,
allFrames: true,
matches,
excludeMatches,
runAt: 'document_start',
};
// register
if ( registered === undefined ) {
context.toAdd.push(directive);
return;
}
// update
if (
arrayEq(registered.js, js, false) === false ||
arrayEq(registered.matches, matches) === false ||
arrayEq(registered.excludeMatches, excludeMatches) === false
) {
context.toRemove.push('css-declarative');
context.toAdd.push(directive);
}
}
/******************************************************************************/
function registerSpecific(context) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const js = [];
for ( const rulesetDetails of rulesetsDetails ) {
const count = rulesetDetails.css?.specific || 0;
if ( count === 0 ) { continue; }
js.push(`/rulesets/scripting/specific/${rulesetDetails.id}.js`);
}
if ( js.length === 0 ) { return; }
const { none, basic, optimal, complete } = filteringModeDetails;
const matches = [
...ut.matchesFromHostnames(optimal),
...ut.matchesFromHostnames(complete),
];
if ( matches.length === 0 ) { return; }
normalizeMatches(matches);
js.unshift('/js/scripting/isolated-api.js');
js.push('/js/scripting/css-specific.js');
const excludeMatches = [];
if ( none.has('all-urls') === false ) {
excludeMatches.push(...ut.matchesFromHostnames(none));
}
if ( basic.has('all-urls') === false ) {
excludeMatches.push(...ut.matchesFromHostnames(basic));
}
const registered = before.get('css-specific');
before.delete('css-specific'); // Important!
const directive = {
id: 'css-specific',
js,
allFrames: true,
matches,
excludeMatches,
runAt: 'document_start',
};
// register
if ( registered === undefined ) {
context.toAdd.push(directive);
return;
}
// update
if (
arrayEq(registered.js, js, false) === false ||
arrayEq(registered.matches, matches) === false ||
arrayEq(registered.excludeMatches, excludeMatches) === false
) {
context.toRemove.push('css-specific');
context.toAdd.push(directive);
}
}
/******************************************************************************/
function registerScriptlet(context, scriptletDetails) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const hasBroadHostPermission =
filteringModeDetails.optimal.has('all-urls') ||
filteringModeDetails.complete.has('all-urls');
const permissionRevokedMatches = [
...ut.matchesFromHostnames(filteringModeDetails.none),
...ut.matchesFromHostnames(filteringModeDetails.basic),
];
const permissionGrantedHostnames = [
...filteringModeDetails.optimal,
...filteringModeDetails.complete,
];
for ( const rulesetId of rulesetsDetails.map(v => v.id) ) {
const scriptletList = scriptletDetails.get(rulesetId);
if ( scriptletList === undefined ) { continue; }
for ( const [ token, details ] of scriptletList ) {
const id = `${rulesetId}.${token}`;
const registered = before.get(id);
const matches = [];
const excludeMatches = [];
let targetHostnames = [];
if ( hasBroadHostPermission ) {
excludeMatches.push(...permissionRevokedMatches);
if ( details.hostnames.length > 100 ) {
targetHostnames = [ '*' ];
} else {
targetHostnames = details.hostnames;
}
} else if ( permissionGrantedHostnames.length !== 0 ) {
if ( details.hostnames.includes('*') ) {
targetHostnames = permissionGrantedHostnames;
} else {
targetHostnames = ut.intersectHostnameIters(
details.hostnames,
permissionGrantedHostnames
);
}
}
if ( targetHostnames.length === 0 ) { continue; }
matches.push(...ut.matchesFromHostnames(targetHostnames));
before.delete(id); // Important!
const directive = {
id,
js: [ `/rulesets/scripting/scriptlet/${id}.js` ],
allFrames: true,
matches,
excludeMatches,
matchOriginAsFallback: true,
runAt: 'document_start',
world: details.world,
};
// register
if ( registered === undefined ) {
context.toAdd.push(directive);
continue;
}
// update
if (
arrayEq(registered.matches, matches) === false ||
arrayEq(registered.excludeMatches, excludeMatches) === false
) {
context.toRemove.push(id);
context.toAdd.push(directive);
}
}
}
}
/******************************************************************************/
async function registerInjectables() {
if ( browser.scripting === undefined ) { return false; }
if ( registerInjectables.barrier ) { return true; }
registerInjectables.barrier = true;
const [
filteringModeDetails,
rulesetsDetails,
scriptletDetails,
genericDetails,
registered,
] = await Promise.all([
getFilteringModeDetails(),
getEnabledRulesetsDetails(),
getScriptletDetails(),
getGenericDetails(),
browser.scripting.getRegisteredContentScripts(),
]);
const before = new Map(
normalizeRegisteredContentScripts(registered).map(
entry => [ entry.id, entry ]
)
);
const toAdd = [], toRemove = [];
const context = {
filteringModeDetails,
rulesetsDetails,
before,
toAdd,
toRemove,
};
registerDeclarative(context);
registerProcedural(context);
registerScriptlet(context, scriptletDetails);
registerSpecific(context);
registerGeneric(context, genericDetails);
registerHighGeneric(context, genericDetails);
toRemove.push(...Array.from(before.keys()));
if ( toRemove.length !== 0 ) {
ubolLog(`Unregistered ${toRemove} content (css/js)`);
await browser.scripting.unregisterContentScripts({ ids: toRemove })
.catch(reason => { console.info(reason); });
}
if ( toAdd.length !== 0 ) {
ubolLog(`Registered ${toAdd.map(v => v.id)} content (css/js)`);
await browser.scripting.registerContentScripts(toAdd)
.catch(reason => { console.info(reason); });
}
registerInjectables.barrier = false;
return true;
}
/******************************************************************************/
export {
registerInjectables
};