Skip to content

Commit 46852eb

Browse files
committed
[mv3] Add support for custom scriptlet filters
At commit time, only Chromium supports custom scriptlet filters, and this also requires that the user enables the "Allow User Scripts" setting in the extension details. Custom scriptlet filters can be entered through the "Import/Export" field in the "Custom filters" pane in the dashboard. Once a custom scriptlet filter i spresent in the list of filters, it can be freely edited. Custom scriptlet filters are enforced in basic and higher filtering modes -- just as is the case with custom cosmetic filters. This new capability is mostly of interest to filter list authors.
1 parent e2bd8c1 commit 46852eb

13 files changed

Lines changed: 306 additions & 60 deletions

platform/mv3/chromium/manifest.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@
4343
"permissions": [
4444
"activeTab",
4545
"declarativeNetRequest",
46+
"offscreen",
4647
"scripting",
47-
"storage"
48+
"storage",
49+
"userScripts"
4850
],
4951
"short_name": "uBO Lite",
5052
"storage": {

platform/mv3/extension/js/fetch.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,10 @@ import { ubolErr } from './debug.js';
2323

2424
/******************************************************************************/
2525

26-
function fetchJSON(path) {
26+
export function fetchJSON(path) {
2727
return fetch(`${path}.json`).then(response =>
2828
response.json()
2929
).catch(reason => {
3030
ubolErr(`fetchJSON/${reason}`);
3131
});
3232
}
33-
34-
/******************************************************************************/
35-
36-
export { fetchJSON };

platform/mv3/extension/js/filter-manager-ui.js

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,16 @@ async function renderCustomFilters() {
156156
...Array.from(storedSelectors),
157157
...Array.from(domSelectors),
158158
])
159-
).sort();
159+
).sort((a, b) => {
160+
const as = a[0] === '+';
161+
const bs = b[0] === '+';
162+
if ( as ) {
163+
return bs && as < bs ? -1 : 1;
164+
} else if ( bs ) {
165+
return -1;
166+
}
167+
return a < b ? -1 : 1;
168+
});
160169
const ulSelectors = qs$(hostnameNode, '.selectors');
161170
for ( const selector of selectors ) {
162171
const selectorNode = nodeFromTemplate('customFiltersSelector');
@@ -251,15 +260,29 @@ async function onHostnameChanged(target, before, after) {
251260

252261
async function onSelectorChanged(target, before, after) {
253262
// Validate selector
254-
const parserModule = await import('./static-filtering-parser.js');
255-
const compiler = new parserModule.ExtSelectorCompiler({ nativeCssHas: true });
256-
const result = {};
257-
if ( compiler.compile(after, result) === false ) {
263+
const sfp = await import('./static-filtering-parser.js');
264+
const parser = new sfp.AstFilterParser({
265+
nativeCssHas: true,
266+
trustedSource: true,
267+
});
268+
const hostname = hostnameFromNode(target);
269+
parser.parse(`##${after}`);
270+
if ( parser.hasError() ) {
271+
target.textContent = before;
272+
return;
273+
}
274+
let prettySelector, uglySelector;
275+
if ( parser.isScriptletFilter() ) {
276+
prettySelector = `+js(${parser.getTypeString(sfp.NODE_TYPE_EXT_PATTERN_SCRIPTLET)})`;
277+
uglySelector = prettySelector;
278+
} else if ( parser.isCosmeticFilter() ) {
279+
prettySelector = parser.getTypeString(sfp.NODE_TYPE_EXT_PATTERN_COSMETIC);
280+
uglySelector = parser.result.compiled;
281+
}
282+
if ( Boolean(prettySelector) === false ) {
258283
target.textContent = before;
259284
return;
260285
}
261-
262-
const hostname = hostnameFromNode(target);
263286

264287
dom.cl.add(dom.body, 'readonly');
265288
updateContentEditability();
@@ -271,11 +294,11 @@ async function onSelectorChanged(target, before, after) {
271294
});
272295

273296
// Add new selector to storage
274-
target.dataset.ugly = result.compiled;
275-
target.dataset.pretty = result.raw;
297+
target.dataset.ugly = uglySelector;
298+
target.dataset.pretty = prettySelector;
276299
await sendMessage({ what: 'addCustomFilters',
277300
hostname,
278-
selectors: [ result.compiled ],
301+
selectors: [ uglySelector ],
279302
});
280303

281304
await debounceRenderCustomFilters();
@@ -285,7 +308,7 @@ async function onSelectorChanged(target, before, after) {
285308

286309
function onTextChanged(target) {
287310
const before = target.dataset.pretty;
288-
const after = target.textContent.trim();
311+
const after = target.textContent.trim().replace('\n', '');
289312
if ( after !== target.textContent ) {
290313
target.textContent = after;
291314
}
@@ -353,19 +376,26 @@ function onUndoClicked(ev) {
353376
/******************************************************************************/
354377

355378
async function importFromText(text) {
356-
const parserModule = await import('./static-filtering-parser.js');
357-
const parser = new parserModule.AstFilterParser({ nativeCssHas: true });
379+
const sfp = await import('./static-filtering-parser.js');
380+
const parser = new sfp.AstFilterParser({
381+
nativeCssHas: true,
382+
trustedSource: true,
383+
});
358384
const lines = text.split(/\n/);
359385
const hostnameToSelectorsMap = new Map();
360386

361387
for ( const line of lines ) {
362388
parser.parse(line);
363389
if ( parser.hasError() ) { continue; }
364-
if ( parser.isCosmeticFilter() === false ) { continue; }
365390
if ( parser.hasOptions() === false ) { continue; }
366-
const { compiled, exception } = parser.result;
367-
if ( compiled === undefined ) { continue; }
368-
if ( exception ) { continue; }
391+
if ( parser.isException() ) { continue; }
392+
let selector;
393+
if ( parser.isScriptletFilter() ) {
394+
selector = `+js(${parser.getTypeString(sfp.NODE_TYPE_EXT_PATTERN_SCRIPTLET)})`;
395+
} else if ( parser.isCosmeticFilter() ) {
396+
selector = parser.result.compiled;
397+
}
398+
if ( Boolean(selector) === false ) { continue; }
369399
const hostnames = new Set();
370400
for ( const { hn, not, bad } of parser.getExtFilterDomainIterator() ) {
371401
if ( bad ) { continue; }
@@ -379,7 +409,7 @@ async function importFromText(text) {
379409
if ( selectors.size === 0 ) {
380410
hostnameToSelectorsMap.set(hn, selectors)
381411
}
382-
selectors.add(compiled);
412+
selectors.add(selector);
383413
}
384414
}
385415

platform/mv3/extension/js/filter-manager.js

Lines changed: 131 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
localRead,
2626
localRemove,
2727
localWrite,
28+
runtime,
2829
} from './ext.js';
2930

3031
import {
@@ -33,7 +34,16 @@ import {
3334
subtractHostnameIters,
3435
} from './utils.js';
3536

36-
import { ubolErr } from './debug.js';
37+
import {
38+
ubolErr,
39+
ubolLog,
40+
} from './debug.js';
41+
42+
/******************************************************************************/
43+
44+
const isProcedural = a => a.startsWith('{');
45+
const isScriptlet = a => a.startsWith('+js');
46+
const isCSS = a => isProcedural(a) === false && isScriptlet(a) === false;
3747

3848
/******************************************************************************/
3949

@@ -82,7 +92,7 @@ export async function customFiltersFromHostname(hostname) {
8292
const selectors = results[i];
8393
if ( selectors === undefined ) { continue; }
8494
selectors.forEach(selector => {
85-
out.push(selector.startsWith('0') ? selector.slice(1) : selector);
95+
out.push(selector);
8696
});
8797
}
8898
return out.sort();
@@ -107,7 +117,7 @@ async function getAllCustomFilterKeys() {
107117
export async function getAllCustomFilters() {
108118
const collect = async key => {
109119
const selectors = await readFromStorage(key);
110-
return [ key.slice(5), selectors.map(a => a.startsWith('0') ? a.slice(1) : a) ];
120+
return [ key.slice(5), selectors ];
111121
};
112122
const keys = await getAllCustomFilterKeys();
113123
const promises = keys.map(k => collect(k));
@@ -142,7 +152,7 @@ export async function injectCustomFilters(tabId, frameId, hostname) {
142152
const selectors = await customFiltersFromHostname(hostname);
143153
if ( selectors.length === 0 ) { return; }
144154
const promises = [];
145-
const plainSelectors = selectors.filter(a => a.startsWith('{') === false);
155+
const plainSelectors = selectors.filter(a => isCSS(a));
146156
if ( plainSelectors.length !== 0 ) {
147157
promises.push(
148158
browser.scripting.insertCSS({
@@ -154,7 +164,7 @@ export async function injectCustomFilters(tabId, frameId, hostname) {
154164
})
155165
);
156166
}
157-
const proceduralSelectors = selectors.filter(a => a.startsWith('{'));
167+
const proceduralSelectors = selectors.filter(a => isProcedural(a));
158168
if ( proceduralSelectors.length !== 0 ) {
159169
promises.push(
160170
browser.scripting.executeScript({
@@ -173,11 +183,11 @@ export async function injectCustomFilters(tabId, frameId, hostname) {
173183
/******************************************************************************/
174184

175185
export async function registerCustomFilters(context) {
176-
const siteKeys = await getAllCustomFilterKeys();
177-
if ( siteKeys.length === 0 ) { return; }
186+
const customFilters = new Map(await getAllCustomFilters());
187+
if ( customFilters.size === 0 ) { return; }
178188

179189
const { none } = context.filteringModeDetails;
180-
let hostnames = siteKeys.map(a => a.slice(5));
190+
let hostnames = Array.from(customFilters.keys());
181191
if ( none.has('all-urls') ) {
182192
const { basic, optimal, complete } = context.filteringModeDetails;
183193
hostnames = intersectHostnameIters(hostnames, [
@@ -186,6 +196,9 @@ export async function registerCustomFilters(context) {
186196
} else if ( none.size !== 0 ) {
187197
hostnames = [ ...subtractHostnameIters(hostnames, none) ];
188198
}
199+
hostnames = hostnames.filter(a => {
200+
return customFilters.get(a).some(a => isCSS(a) || isProcedural(a));
201+
});
189202
if ( hostnames.length === 0 ) { return; }
190203

191204
const directive = {
@@ -251,10 +264,6 @@ async function removeCustomFiltersByKey(key, toRemove) {
251264
const beforeCount = selectors.length;
252265
for ( const selector of toRemove ) {
253266
let i = selectors.indexOf(selector);
254-
if ( i === -1 ) {
255-
i = selectors.indexOf(`0${selector}`);
256-
if ( i === -1 ) { continue; }
257-
}
258267
selectors.splice(i, 1);
259268
}
260269
const afterCount = selectors.length;
@@ -266,3 +275,113 @@ async function removeCustomFiltersByKey(key, toRemove) {
266275
}
267276
return true;
268277
}
278+
279+
/******************************************************************************/
280+
281+
export function isUserScriptsAvailable() {
282+
if ( browser.offscreen === undefined ) { return false; }
283+
try {
284+
chrome.userScripts.getScripts();
285+
} catch {
286+
return false;
287+
}
288+
return true;
289+
}
290+
291+
export async function registerCustomScriptlets(context) {
292+
if ( isUserScriptsAvailable() === false ) { return; }
293+
const { none, basic, optimal, complete } = context.filteringModeDetails;
294+
const notNone = [ ...basic, ...optimal, ...complete ];
295+
if ( registerCustomScriptlets.promise ) {
296+
registerCustomScriptlets.promise = registerCustomScriptlets.promise.then(( ) =>
297+
registerCustomScriptlets.create().then(worlds =>
298+
registerCustomScriptlets.register(worlds, none, notNone)
299+
)
300+
);
301+
} else {
302+
registerCustomScriptlets.promise = registerCustomScriptlets.create().then(worlds =>
303+
registerCustomScriptlets.register(worlds, none, notNone)
304+
);
305+
}
306+
return registerCustomScriptlets.promise;
307+
}
308+
309+
registerCustomScriptlets.register = async function(worlds, none, notNone) {
310+
if ( Boolean(worlds) === false ) { return; }
311+
const toAdd = [];
312+
const prepare = world => {
313+
let { hostnames } = world;
314+
if ( none.has('all-urls') ) {
315+
hostnames = intersectHostnameIters(hostnames, notNone);
316+
} else if ( none.size !== 0 ) {
317+
hostnames = [ ...subtractHostnameIters(hostnames, none) ];
318+
}
319+
if ( hostnames.length === 0 ) { return; }
320+
return {
321+
allFrames: true,
322+
js: [ { code: world.code } ],
323+
matches: matchesFromHostnames(hostnames),
324+
runAt: 'document_start',
325+
};
326+
};
327+
if ( worlds.ISOLATED ) {
328+
const directive = prepare(worlds.ISOLATED);
329+
if ( directive ) {
330+
directive.id = 'user.isolated';
331+
directive.world = 'USER_SCRIPT';
332+
toAdd.push(directive);
333+
}
334+
}
335+
if ( worlds.MAIN ) {
336+
const directive = prepare(worlds.MAIN);
337+
if ( directive ) {
338+
directive.id = 'user.main';
339+
directive.world = 'MAIN';
340+
toAdd.push(directive);
341+
}
342+
}
343+
if ( toAdd.length === 0 ) { return; }
344+
await browser.userScripts.register(toAdd).then(( ) => {
345+
ubolLog(`Registered userscript ${toAdd.map(v => v.id)}`);
346+
});
347+
};
348+
349+
registerCustomScriptlets.create = async function() {
350+
const toRemove = await browser.userScripts.getScripts();
351+
if ( toRemove.length !== 0 ) {
352+
await browser.userScripts.unregister();
353+
ubolLog(`Unregistered userscript ${toRemove.map(v => v.id)}`);
354+
}
355+
const { promise: offscreenPromise, resolve: offscreenResolve } = Promise.withResolvers();
356+
const handler = (msg, sender, callback) => {
357+
if ( typeof msg !== 'object' ) { return; }
358+
switch ( msg?.what ) {
359+
case 'getAllCustomFilters':
360+
getAllCustomFilters().then(result => {
361+
callback(result);
362+
});
363+
break;
364+
case 'registerCustomScriptlets':
365+
offscreenResolve(msg);
366+
break;
367+
default:
368+
break;
369+
}
370+
};
371+
const { promise: timeoutPromise, resolve: timeoutResolve } = Promise.withResolvers();
372+
self.setTimeout(timeoutResolve, 1000);
373+
runtime.onMessage.addListener(handler);
374+
const [ worlds ] = await Promise.all([
375+
Promise.race([ offscreenPromise, timeoutPromise ]),
376+
browser.offscreen.createDocument({
377+
url: '/js/offscreen/compile-scriptlets.html',
378+
reasons: [ 'WORKERS' ],
379+
justification: 'To compile custom user script-based filters in a modular way from service worker (service workers do not allow dynamic module import)',
380+
}),
381+
]);
382+
runtime.onMessage.removeListener(handler);
383+
await browser.offscreen.closeDocument();
384+
return worlds;
385+
};
386+
387+
registerCustomScriptlets.promise = null;

0 commit comments

Comments
 (0)