uBlock/platform/mv3/extension/js/scripting-manager.js

560 lines
17 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 isGecko = browser.runtime.getURL('').startsWith('moz-extension://');
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;
};
/******************************************************************************/
// 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 css = [];
for ( const details of rulesetsDetails ) {
const hostnames = genericDetails.get(details.id);
if ( hostnames !== undefined ) {
excludeHostnames.push(...hostnames);
}
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,
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 excludeHostnames = [];
const js = [];
for ( const details of rulesetsDetails ) {
const hostnames = genericDetails.get(details.id);
if ( hostnames !== undefined ) {
excludeHostnames.push(...hostnames);
}
const count = details.css?.generic || 0;
if ( count === 0 ) { continue; }
js.push(`/rulesets/scripting/generic/${details.id}.js`);
}
if ( js.length === 0 ) { return; }
js.push('/js/scripting/css-generic.js');
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-generic');
before.delete('css-generic'); // Important!
const directive = {
id: 'css-generic',
js,
matches,
excludeMatches,
runAt: 'document_idle',
};
// 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-generic');
context.toAdd.push(directive);
}
}
/******************************************************************************/
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; }
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; }
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; }
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, scriptletHostnames ] of scriptletList ) {
const id = `${rulesetId}.${token}`;
const registered = before.get(id);
const matches = [];
const excludeMatches = [];
let targetHostnames = [];
if ( hasBroadHostPermission ) {
excludeMatches.push(...permissionRevokedMatches);
if ( scriptletHostnames.length > 100 ) {
targetHostnames = [ '*' ];
} else {
targetHostnames = scriptletHostnames;
}
} else if ( permissionGrantedHostnames.length !== 0 ) {
if ( scriptletHostnames.includes('*') ) {
targetHostnames = permissionGrantedHostnames;
} else {
targetHostnames = ut.intersectHostnameIters(
permissionGrantedHostnames,
scriptletHostnames
);
}
}
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,
runAt: 'document_start',
};
// https://bugzilla.mozilla.org/show_bug.cgi?id=1736575
// `MAIN` world not yet supported in Firefox
if ( isGecko === false ) {
directive.world = 'MAIN';
directive.matchOriginAsFallback = true;
}
// 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(origins) {
void origins;
if ( browser.scripting === undefined ) { return false; }
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); });
}
return true;
}
/******************************************************************************/
export {
registerInjectables
};