/******************************************************************************* 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 */ /* jshint esversion:11 */ 'use strict'; /******************************************************************************/ import { browser } from './ext.js'; import { fetchJSON } from './fetch.js'; import { getFilteringModeDetails } from './mode-manager.js'; import { getEnabledRulesetsDetails } from './ruleset-manager.js'; import * as ut from './utils.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(''); } 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(''); } 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'; } // 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, ] = 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) { ut.ubolLog(`Unregistered ${toRemove} content (css/js)`); browser.scripting.unregisterContentScripts({ ids: toRemove }) .catch(reason => { console.info(reason); }); } if (toAdd.length !== 0) { ut.ubolLog(`Registered ${toAdd.map(v => v.id)} content (css/js)`); browser.scripting.registerContentScripts(toAdd) .catch(reason => { console.info(reason); }); } return true; } /******************************************************************************/ export { registerInjectables };