Support injecting scriptlet in MAIN or ISOLATED world

This reflects the _world_ of the MV3 scripting API:
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld

MAIN: page's world
ISOLATED: extension's content script world

Some scriptlets are best executed in either world, so this
commit allows to pick in which world a scriptlet should execute
(default to MAIN).

For instance, the new sed.js scriptlet will now execute in
the ISOLATED world.
This commit is contained in:
Raymond Hill 2023-05-22 20:19:00 -04:00
parent 9f465f503a
commit 1a863a877d
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
5 changed files with 140 additions and 79 deletions

View File

@ -2218,6 +2218,7 @@ builtinScriptlets.push({
name: 'sed.js',
requiresTrust: true,
fn: sed,
world: 'ISOLATED',
dependencies: [
'pattern-to-regex.fn',
'run-at.fn',
@ -2229,7 +2230,6 @@ function sed(
pattern = '',
replacement = ''
) {
if ( document.documentElement === null ) { return; }
const reNodeName = patternToRegex(nodeName, 'i');
const rePattern = patternToRegex(pattern, 'gms');
const extraArgs = new Map(
@ -2274,22 +2274,18 @@ function sed(
}
};
const observer = new MutationObserver(handleMutations);
observer.observe(document.documentElement, { childList: true, subtree: true });
{
observer.observe(document, { childList: true, subtree: true });
if ( document.documentElement ) {
const treeWalker = document.createTreeWalker(
document.documentElement,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
);
const currentScriptNode = document.currentScript;
const currentTextNode = currentScriptNode.firstChild;
let count = 0;
for (;;) {
const node = treeWalker.nextNode();
count += 1;
if ( node === null ) { break; }
if ( reNodeName.test(node.nodeName) === false ) { continue; }
if ( node === currentScriptNode ) { continue; }
if ( node === currentTextNode ) { continue; }
if ( handleNode(node) ) { continue; }
stop(); break;
}

View File

@ -315,7 +315,7 @@ vAPI.scriptletsInjector = ((doc, details) => {
script = doc.createElement('script');
script.async = false;
script.src = url;
(doc.head || doc.documentElement).appendChild(script);
doc.append(script);
self.uBO_scriptletsInjected = details.filters;
} catch (ex) {
}

View File

@ -1324,9 +1324,9 @@ vAPI.DOMFilterer = class {
// https://github.com/gorhill/uBlock/blob/master/assets/ublock/resources.txt
if ( scriptletDetails && typeof self.uBO_scriptletsInjected !== 'string' ) {
self.uBO_scriptletsInjected = scriptletDetails.filters;
if ( scriptletDetails.scriptlets ) {
vAPI.injectScriptlet(document, scriptletDetails.scriptlets);
vAPI.injectedScripts = scriptletDetails.scriptlets;
if ( scriptletDetails.mainWorld ) {
vAPI.injectScriptlet(document, scriptletDetails.mainWorld);
vAPI.injectedScripts = scriptletDetails.mainWorld;
}
}

View File

@ -87,6 +87,7 @@ class RedirectEntry {
this.warURL = undefined;
this.params = undefined;
this.requiresTrust = false;
this.world = 'MAIN';
this.dependencies = [];
}
@ -157,6 +158,7 @@ class RedirectEntry {
r.requiresTrust = details.requiresTrust === true;
r.warURL = details.warURL !== undefined && details.warURL || undefined;
r.params = details.params !== undefined && details.params || undefined;
r.world = details.world || 'MAIN';
if ( Array.isArray(details.dependencies) ) {
r.dependencies.push(...details.dependencies);
}
@ -227,6 +229,7 @@ class RedirectEngine {
if ( entry.mime.startsWith(mime) === false ) { return; }
return {
js: entry.toContent(),
world: entry.world,
dependencies: entry.dependencies.slice(),
};
}
@ -320,6 +323,7 @@ class RedirectEngine {
data: fn.toString(),
dependencies: scriptlet.dependencies,
requiresTrust: scriptlet.requiresTrust === true,
world: scriptlet.world || 'MAIN',
});
this.resources.set(name, entry);
if ( Array.isArray(aliases) === false ) { continue; }

View File

@ -82,7 +82,7 @@ const scriptletFilteringEngine = {
// Consequently, the programmatic-injection code path is taken only with
// Chromium-based browsers.
const contentscriptCode = (( ) => {
const mainWorldInjector = (( ) => {
const parts = [
'(',
function(injector, details) {
@ -95,7 +95,7 @@ const contentscriptCode = (( ) => {
return;
}
injector(doc, details);
if ( typeof self.uBO_scriptletsInjected === 'string' ) { return 0; }
return 0;
}.toString(),
')(',
vAPI.scriptletsInjector, ', ',
@ -112,7 +112,39 @@ const contentscriptCode = (( ) => {
filters,
});
return this.parts.join('');
},
};
})();
const isolatedWorldInjector = (( ) => {
const parts = [
'(',
function(details) {
const doc = document;
if (
doc.location === null ||
details.hostname !== doc.location.hostname ||
self.uBO_isolatedScriptlets === 'done'
) {
return;
}
const isolatedScriptlets = function(){};
isolatedScriptlets();
self.uBO_isolatedScriptlets = 'done';
return 0;
}.toString(),
')(',
'json-slot',
');',
];
return {
parts,
jsonSlot: parts.indexOf('json-slot'),
scriptletSlot: parts.indexOf('scriptlet-slot'),
assemble: function(hostname, scriptlets) {
this.parts[this.jsonSlot] = JSON.stringify({ hostname });
return this.parts.join('').replace('function(){}', scriptlets);
},
};
})();
@ -147,8 +179,8 @@ const normalizeRawFilter = function(parser, sourceIsTrusted = false) {
return `+js(${args.join(', ')})`;
};
const lookupScriptlet = function(rawToken, scriptletMap, dependencyMap) {
if ( scriptletMap.has(rawToken) ) { return; }
const lookupScriptlet = function(rawToken, mainMap, isolatedMap) {
if ( mainMap.has(rawToken) || isolatedMap.has(rawToken) ) { return; }
const pos = rawToken.indexOf(',');
let token, args = '';
if ( pos === -1 ) {
@ -157,8 +189,6 @@ const lookupScriptlet = function(rawToken, scriptletMap, dependencyMap) {
token = rawToken.slice(0, pos).trim();
args = rawToken.slice(pos + 1).trim();
}
// TODO: The alias lookup can be removed once scriptlet resources
// with obsolete name are converted to their new name.
if ( reng.aliases.has(token) ) {
token = reng.aliases.get(token);
} else {
@ -166,18 +196,19 @@ const lookupScriptlet = function(rawToken, scriptletMap, dependencyMap) {
}
const details = reng.contentFromName(token, 'text/javascript');
if ( details === undefined ) { return; }
const targetWorldMap = details.world !== 'ISOLATED' ? mainMap : isolatedMap;
const content = patchScriptlet(details.js, args);
const dependencies = details.dependencies || [];
while ( dependencies.length !== 0 ) {
const token = dependencies.shift();
if ( dependencyMap.has(token) ) { continue; }
if ( targetWorldMap.has(token) ) { continue; }
const details = reng.contentFromName(token, 'fn/javascript');
if ( details === undefined ) { continue; }
dependencyMap.set(token, details.js);
targetWorldMap.set(token, details.js);
if ( Array.isArray(details.dependencies) === false ) { continue; }
dependencies.push(...details.dependencies);
}
scriptletMap.set(rawToken, [
targetWorldMap.set(rawToken, [
'try {',
'// >>>> scriptlet start',
content,
@ -314,24 +345,14 @@ scriptletFilteringEngine.fromCompiledContent = function(reader) {
const $scriptlets = new Set();
const $exceptions = new Set();
const $scriptletMap = new Map();
const $scriptletDependencyMap = new Map();
const $mainWorldMap = new Map();
const $isolatedWorldMap = new Map();
scriptletFilteringEngine.retrieve = function(request) {
if ( scriptletDB.size === 0 ) { return; }
const hostname = request.hostname;
$scriptlets.clear();
$exceptions.clear();
scriptletDB.retrieve(hostname, [ $scriptlets, $exceptions ]);
const entity = request.entity !== ''
? `${hostname.slice(0, -request.domain.length)}${request.entity}`
: '*';
scriptletDB.retrieve(entity, [ $scriptlets, $exceptions ], 1);
if ( $scriptlets.size === 0 ) { return; }
// https://github.com/gorhill/uBlock/issues/2835
// Do not inject scriptlets if the site is under an `allow` rule.
if (
@ -341,6 +362,22 @@ scriptletFilteringEngine.retrieve = function(request) {
return;
}
if ( scriptletCache.resetTime < reng.modifyTime ) {
scriptletCache.reset();
}
let cacheDetails = scriptletCache.lookup(hostname);
if ( cacheDetails === undefined ) {
$scriptlets.clear();
$exceptions.clear();
scriptletDB.retrieve(hostname, [ $scriptlets, $exceptions ]);
const entity = request.entity !== ''
? `${hostname.slice(0, -request.domain.length)}${request.entity}`
: '*';
scriptletDB.retrieve(entity, [ $scriptlets, $exceptions ], 1);
if ( $scriptlets.size === 0 ) { return; }
// Wholly disable scriptlet injection?
if ( $exceptions.has('') ) {
return {
@ -350,13 +387,6 @@ scriptletFilteringEngine.retrieve = function(request) {
};
}
if ( scriptletCache.resetTime < reng.modifyTime ) {
scriptletCache.reset();
}
let cacheDetails = scriptletCache.lookup(hostname);
if ( cacheDetails === undefined ) {
const fullCode = [];
for ( const token of $exceptions ) {
if ( $scriptlets.has(token) ) {
$scriptlets.delete(token);
@ -365,27 +395,30 @@ scriptletFilteringEngine.retrieve = function(request) {
}
}
for ( const token of $scriptlets ) {
lookupScriptlet(token, $scriptletMap, $scriptletDependencyMap);
lookupScriptlet(token, $mainWorldMap, $isolatedWorldMap);
}
for ( const token of $scriptlets ) {
fullCode.push($scriptletMap.get(token));
const mainWorldCode = [];
for ( const js of $mainWorldMap.values() ) {
mainWorldCode.push(js);
}
for ( const code of $scriptletDependencyMap.values() ) {
fullCode.push(code);
const isolatedWorldCode = [];
for ( const js of $isolatedWorldMap.values() ) {
isolatedWorldCode.push(js);
}
cacheDetails = {
code: fullCode.join('\n\n'),
mainWorld: mainWorldCode.join('\n\n'),
isolatedWorld: isolatedWorldCode.join('\n\n'),
filters: [
...Array.from($scriptlets).map(s => `##+js(${s})`),
...Array.from($exceptions).map(s => `#@#+js(${s})`),
].join('\n'),
};
scriptletCache.add(hostname, cacheDetails);
$scriptletMap.clear();
$scriptletDependencyMap.clear();
$mainWorldMap.clear();
$isolatedWorldMap.clear();
}
if ( cacheDetails.code === '' ) {
if ( cacheDetails.mainWorld === '' && cacheDetails.isolatedWorld === '' ) {
return { filters: cacheDetails.filters };
}
@ -398,7 +431,8 @@ scriptletFilteringEngine.retrieve = function(request) {
scriptletGlobals.push([ 'canDebug', true ]);
}
const out = [
return {
mainWorld: cacheDetails.mainWorld === '' ? '' : [
'(function() {',
'// >>>> start of private namespace',
'',
@ -407,13 +441,27 @@ scriptletFilteringEngine.retrieve = function(request) {
// For use by scriptlets to share local data among themselves
`const scriptletGlobals = new Map(${JSON.stringify(scriptletGlobals, null, 2)});`,
'',
cacheDetails.code,
cacheDetails.mainWorld,
'',
'// <<<< end of private namespace',
'})();',
];
return { scriptlets: out.join('\n'), filters: cacheDetails.filters };
].join('\n'),
isolatedWorld: cacheDetails.isolatedWorld === '' ? '' : [
'function() {',
'// >>>> start of private namespace',
'',
µb.hiddenSettings.debugScriptlets ? 'debugger;' : ';',
'',
// For use by scriptlets to share local data among themselves
`const scriptletGlobals = new Map(${JSON.stringify(scriptletGlobals, null, 2)});`,
'',
cacheDetails.isolatedWorld,
'',
'// <<<< end of private namespace',
'}',
].join('\n'),
filters: cacheDetails.filters,
};
};
scriptletFilteringEngine.injectNow = function(details) {
@ -430,9 +478,9 @@ scriptletFilteringEngine.injectNow = function(details) {
request.entity = entityFromDomain(request.domain);
const scriptletDetails = this.retrieve(request);
if ( scriptletDetails === undefined ) { return; }
const { scriptlets = '', filters } = scriptletDetails;
if ( scriptlets === '' ) { return scriptletDetails; }
let code = contentscriptCode.assemble(request.hostname, scriptlets, filters);
const { mainWorld = '', isolatedWorld = '', filters } = scriptletDetails;
if ( mainWorld !== '' ) {
let code = mainWorldInjector.assemble(request.hostname, mainWorld, filters);
if ( µb.hiddenSettings.debugScriptletInjector ) {
code = 'debugger;\n' + code;
}
@ -442,6 +490,19 @@ scriptletFilteringEngine.injectNow = function(details) {
matchAboutBlank: true,
runAt: 'document_start',
});
}
if ( isolatedWorld !== '' ) {
let code = isolatedWorldInjector.assemble(request.hostname, isolatedWorld);
if ( µb.hiddenSettings.debugScriptletInjector ) {
code = 'debugger;\n' + code;
}
vAPI.tabs.executeScript(details.tabId, {
code,
frameId: details.frameId,
matchAboutBlank: true,
runAt: 'document_start',
});
}
return scriptletDetails;
};