[mv3] Add support to add/remove rulesets through policies

Related discussion:
https://github.com/uBlockOrigin/uBOL-home/discussions/35#discussioncomment-11157444

New policy setting: `rulesets`
Type: array
Type of array items: string

Each item in the list is a list id (as seen in `rulesets/ruleset-details.json`),
prefixed with either `+` to enable the ruleset, or `-` to disable the ruleset.

Users will not be able to enable or disable rulesets present in the `rulesets`
policy. Disabled rulesets will not appear in the dashboard.

Use `-*` to remove all non-default rulesets, except for those added
using `+[ruleset_id]`.

Additionally, some work has been done to properly handle policy changes in
a non-blocking and deferred manner, as I observed that it often takes long
for calls to `storage.manage.get` to resolve. This potentailly takes care
of the following issue:
https://github.com/uBlockOrigin/uBOL-home/issues/174
This commit is contained in:
Raymond Hill 2024-11-11 13:20:54 -05:00
parent 74921a0f27
commit 15dae359f7
No known key found for this signature in database
GPG Key ID: 25E1490B761470C2
10 changed files with 356 additions and 117 deletions

View File

@ -1,7 +1,8 @@
# https://stackoverflow.com/a/6273809
run_options := $(filter-out $@,$(MAKECMDGOALS))
.PHONY: all clean cleanassets test lint chromium opera firefox npm dig mv3 mv3-quick \
.PHONY: all clean cleanassets test lint chromium opera firefox npm dig \
mv3 mv3-quick mv3-chromium mv3-firefox \
compare maxcost medcost mincost modifiers record wasm
sources := $(wildcard assets/* assets/*/* dist/version src/* src/*/* src/*/*/* src/*/*/*/*)
@ -55,12 +56,16 @@ dig: dist/build/uBlock0.dig
dig-snfe: dig
cd dist/build/uBlock0.dig && npm run snfe $(run_options)
mv3-chromium: tools/make-mv3.sh $(sources) $(platform)
dist/build/uBOLite.chromium: tools/make-mv3.sh $(sources) $(platform)
tools/make-mv3.sh chromium
mv3-firefox: tools/make-mv3.sh $(sources) $(platform)
mv3-chromium: dist/build/uBOLite.chromium
dist/build/uBOLite.firefox: tools/make-mv3.sh $(sources) $(platform)
tools/make-mv3.sh firefox
mv3-firefox: dist/build/uBOLite.firefox
mv3-quick: tools/make-mv3.sh $(sources) $(platform)
tools/make-mv3.sh quick

View File

@ -114,11 +114,19 @@ h3[data-i18n="filteringMode0Name"]::first-letter {
.groupEntry:not([data-groupkey="user"]) .listEntry:not(.isDefault).unused {
display: none;
}
.listEntry.fromAdmin:has(input[disabled]:not(:checked)) {
display: none;
}
.listEntry > * {
margin-left: 0;
margin-right: 0;
unicode-bidi: embed;
}
.listEntry .checkbox:has(input[disabled]),
.listEntry .checkbox:has(input[disabled]) ~ span {
filter: var(--checkbox-disabled-filter);
}
.listEntry .listname {
white-space: nowrap;
}

View File

@ -0,0 +1,121 @@
/*******************************************************************************
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 {
adminRead,
localRead, localWrite,
sessionRead, sessionWrite,
} from './ext.js';
import {
enableRulesets,
getRulesetDetails,
} from './ruleset-manager.js';
import {
getTrustedSites,
readFilteringModeDetails,
} from './mode-manager.js';
import { broadcastMessage } from './utils.js';
import { dnr } from './ext.js';
import { registerInjectables } from './scripting-manager.js';
import { rulesetConfig } from './config.js';
import { ubolLog } from './debug.js';
/******************************************************************************/
const adminSettings = {
keys: new Set(),
timer: undefined,
change(key) {
this.keys.add(key);
if ( this.timer !== undefined ) { return; }
this.timer = self.setTimeout(( ) => {
this.timer = undefined;
this.process();
}, 127);
},
async process() {
if ( this.keys.has('rulesets') ) {
ubolLog('admin setting "rulesets" changed');
await enableRulesets(rulesetConfig.enabledRulesets);
await registerInjectables();
const results = await Promise.all([
getAdminRulesets(),
dnr.getEnabledRulesets(),
]);
const [ adminRulesets, enabledRulesets ] = results;
broadcastMessage({ adminRulesets, enabledRulesets });
}
if ( this.keys.has('noFiltering') ) {
ubolLog('admin setting "noFiltering" changed');
await readFilteringModeDetails(true);
const trustedSites = await getTrustedSites();
broadcastMessage({ trustedSites: Array.from(trustedSites) });
}
this.keys.clear();
}
};
/******************************************************************************/
export async function getAdminRulesets() {
const adminList = await adminReadEx('rulesets');
const adminRulesets = new Set(Array.isArray(adminList) && adminList || []);
if ( adminRulesets.has('-*') ) {
adminRulesets.delete('-*');
const rulesetDetails = await getRulesetDetails();
for ( const ruleset of rulesetDetails.values() ) {
if ( ruleset.enabled ) { continue; }
if ( adminRulesets.has(`+${ruleset.id}`) ) { continue; }
adminRulesets.add(`-${ruleset.id}`);
}
}
return Array.from(adminRulesets);
}
/******************************************************************************/
export async function adminReadEx(key) {
let cacheValue;
const session = await sessionRead(`admin_${key}`);
if ( session ) {
cacheValue = session.data;
} else {
const local = await localRead(`admin_${key}`);
if ( local ) {
cacheValue = local.data;
}
}
adminRead(key).then(async value => {
const adminKey = `admin_${key}`;
await Promise.all([
sessionWrite(adminKey, { data: value }),
localWrite(adminKey, { data: value }),
]);
if ( JSON.stringify(value) === JSON.stringify(cacheValue) ) { return; }
adminSettings.change(key);
});
return cacheValue;
}
/******************************************************************************/

View File

@ -19,24 +19,6 @@
Home: https://github.com/gorhill/uBlock
*/
import {
adminRead,
browser,
dnr,
localRead, localWrite,
runtime,
sessionRead, sessionWrite,
windows,
} from './ext.js';
import {
defaultRulesetsFromLanguage,
enableRulesets,
getEnabledRulesetsDetails,
getRulesetDetails,
updateDynamicRules,
} from './ruleset-manager.js';
import {
MODE_BASIC,
MODE_OPTIMAL,
@ -49,75 +31,51 @@ import {
syncWithBrowserPermissions,
} from './mode-manager.js';
import {
adminRead,
browser,
dnr,
localRead, localWrite,
runtime,
windows,
} from './ext.js';
import {
enableRulesets,
getEnabledRulesetsDetails,
getRulesetDetails,
updateDynamicRules,
} from './ruleset-manager.js';
import {
getMatchedRules,
isSideloaded,
ubolLog,
} from './debug.js';
import {
loadRulesetConfig,
process,
rulesetConfig,
saveRulesetConfig,
} from './config.js';
import { broadcastMessage } from './utils.js';
import { getAdminRulesets } from './admin.js';
import { registerInjectables } from './scripting-manager.js';
/******************************************************************************/
const rulesetConfig = {
version: '',
enabledRulesets: [ 'default' ],
autoReload: true,
showBlockedCount: true,
};
const UBOL_ORIGIN = runtime.getURL('').replace(/\/$/, '');
const canShowBlockedCount = typeof dnr.setExtensionActionOptions === 'function';
let firstRun = false;
let wakeupRun = false;
/******************************************************************************/
function getCurrentVersion() {
return runtime.getManifest().version;
}
async function loadRulesetConfig() {
let data = await sessionRead('rulesetConfig');
if ( data ) {
rulesetConfig.version = data.version;
rulesetConfig.enabledRulesets = data.enabledRulesets;
rulesetConfig.autoReload = typeof data.autoReload === 'boolean'
? data.autoReload
: true;
rulesetConfig.showBlockedCount = typeof data.showBlockedCount === 'boolean'
? data.showBlockedCount
: true;
wakeupRun = true;
return;
}
data = await localRead('rulesetConfig');
if ( data ) {
rulesetConfig.version = data.version;
rulesetConfig.enabledRulesets = data.enabledRulesets;
rulesetConfig.autoReload = typeof data.autoReload === 'boolean'
? data.autoReload
: true;
rulesetConfig.showBlockedCount = typeof data.showBlockedCount === 'boolean'
? data.showBlockedCount
: true;
sessionWrite('rulesetConfig', rulesetConfig);
return;
}
rulesetConfig.enabledRulesets = await defaultRulesetsFromLanguage();
sessionWrite('rulesetConfig', rulesetConfig);
localWrite('rulesetConfig', rulesetConfig);
firstRun = true;
}
async function saveRulesetConfig() {
sessionWrite('rulesetConfig', rulesetConfig);
return localWrite('rulesetConfig', rulesetConfig);
}
/******************************************************************************/
async function hasGreatPowers(origin) {
@ -216,7 +174,9 @@ function onMessage(request, sender, callback) {
}).then(( ) => {
registerInjectables();
callback();
broadcastMessage({ enabledRulesets: rulesetConfig.enabledRulesets });
return dnr.getEnabledRulesets();
}).then(enabledRulesets => {
broadcastMessage({ enabledRulesets });
});
return true;
}
@ -227,25 +187,28 @@ function onMessage(request, sender, callback) {
getTrustedSites(),
getRulesetDetails(),
dnr.getEnabledRulesets(),
getAdminRulesets(),
]).then(results => {
const [
defaultFilteringMode,
trustedSites,
rulesetDetails,
enabledRulesets,
adminRulesets,
] = results;
callback({
defaultFilteringMode,
trustedSites: Array.from(trustedSites),
enabledRulesets,
adminRulesets,
maxNumberOfEnabledRulesets: dnr.MAX_NUMBER_OF_ENABLED_STATIC_RULESETS,
rulesetDetails: Array.from(rulesetDetails.values()),
autoReload: rulesetConfig.autoReload,
showBlockedCount: rulesetConfig.showBlockedCount,
canShowBlockedCount,
firstRun,
firstRun: process.firstRun,
});
firstRun = false;
process.firstRun = false;
});
return true;
}
@ -367,6 +330,8 @@ function onMessage(request, sender, callback) {
default:
break;
}
return false;
}
/******************************************************************************/
@ -374,12 +339,12 @@ function onMessage(request, sender, callback) {
async function start() {
await loadRulesetConfig();
if ( wakeupRun === false ) {
if ( process.wakeupRun === false ) {
await enableRulesets(rulesetConfig.enabledRulesets);
}
// We need to update the regex rules only when ruleset version changes.
if ( wakeupRun === false ) {
if ( process.wakeupRun === false ) {
const currentVersion = getCurrentVersion();
if ( currentVersion !== rulesetConfig.version ) {
ubolLog(`Version change: ${rulesetConfig.version} => ${currentVersion}`);
@ -396,7 +361,7 @@ async function start() {
// Unsure whether the browser remembers correctly registered css/scripts
// after we quit the browser. For now uBOL will check unconditionally at
// launch time whether content css/scripts are properly registered.
if ( wakeupRun === false || permissionsChanged ) {
if ( process.wakeupRun === false || permissionsChanged ) {
registerInjectables();
const enabledRulesets = await dnr.getEnabledRulesets();
@ -409,7 +374,7 @@ async function start() {
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/declarativeNetRequest
// Firefox API does not support `dnr.setExtensionActionOptions`
if ( wakeupRun === false && canShowBlockedCount ) {
if ( process.wakeupRun === false && canShowBlockedCount ) {
dnr.setExtensionActionOptions({
displayActionCountAsBadgeText: rulesetConfig.showBlockedCount,
});
@ -421,7 +386,7 @@ async function start() {
( ) => { onPermissionsRemoved(); }
);
if ( firstRun ) {
if ( process.firstRun ) {
const enableOptimal = await hasOmnipotence();
if ( enableOptimal ) {
const afterLevel = await setDefaultFilteringMode(MODE_OPTIMAL);
@ -434,7 +399,7 @@ async function start() {
if ( disableFirstRunPage !== true ) {
runtime.openOptionsPage();
} else {
firstRun = false;
process.firstRun = false;
}
}
}

View File

@ -0,0 +1,81 @@
/*******************************************************************************
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 {
localRead, localWrite,
sessionRead, sessionWrite,
} from './ext.js';
import { defaultRulesetsFromLanguage } from './ruleset-manager.js';
/******************************************************************************/
export const rulesetConfig = {
version: '',
enabledRulesets: [ 'default' ],
autoReload: true,
showBlockedCount: true,
};
export const process = {
firstRun: false,
wakeupRun: false,
};
/******************************************************************************/
export async function loadRulesetConfig() {
const sessionData = await sessionRead('rulesetConfig');
if ( sessionData ) {
rulesetConfig.version = sessionData.version;
rulesetConfig.enabledRulesets = sessionData.enabledRulesets;
rulesetConfig.autoReload = typeof sessionData.autoReload === 'boolean'
? sessionData.autoReload
: true;
rulesetConfig.showBlockedCount = typeof sessionData.showBlockedCount === 'boolean'
? sessionData.showBlockedCount
: true;
process.wakeupRun = true;
return;
}
const localData = await localRead('rulesetConfig');
if ( localData ) {
rulesetConfig.version = localData.version;
rulesetConfig.enabledRulesets = localData.enabledRulesets;
rulesetConfig.autoReload = typeof localData.autoReload === 'boolean'
? localData.autoReload
: true;
rulesetConfig.showBlockedCount = typeof localData.showBlockedCount === 'boolean'
? localData.showBlockedCount
: true;
sessionWrite('rulesetConfig', rulesetConfig);
return;
}
rulesetConfig.enabledRulesets = await defaultRulesetsFromLanguage();
sessionWrite('rulesetConfig', rulesetConfig);
localWrite('rulesetConfig', rulesetConfig);
process.firstRun = true;
}
export async function saveRulesetConfig() {
sessionWrite('rulesetConfig', rulesetConfig);
return localWrite('rulesetConfig', rulesetConfig);
}

View File

@ -24,14 +24,6 @@ import {
getDynamicRules,
} from './ruleset-manager.js';
import {
adminRead,
browser,
dnr,
localRead, localRemove, localWrite,
sessionRead, sessionWrite,
} from './ext.js';
import {
broadcastMessage,
hostnamesFromMatches,
@ -39,6 +31,15 @@ import {
toBroaderHostname,
} from './utils.js';
import {
browser,
dnr,
localRead, localWrite,
sessionRead, sessionWrite,
} from './ext.js';
import { adminReadEx } from './admin.js';
/******************************************************************************/
// 0: no filtering
@ -224,7 +225,8 @@ function applyFilteringMode(filteringModes, hostname, afterLevel) {
/******************************************************************************/
async function readFilteringModeDetails() {
export async function readFilteringModeDetails(bypassCache = false) {
if ( bypassCache === false ) {
if ( readFilteringModeDetails.cache ) {
return readFilteringModeDetails.cache;
}
@ -233,29 +235,30 @@ async function readFilteringModeDetails() {
readFilteringModeDetails.cache = unserializeModeDetails(sessionModes);
return readFilteringModeDetails.cache;
}
}
let [ userModes, adminNoFiltering ] = await Promise.all([
localRead('filteringModeDetails'),
localRead('adminNoFiltering'),
adminReadEx('noFiltering'),
]);
if ( userModes === undefined ) {
userModes = { basic: [ 'all-urls' ] };
}
userModes = unserializeModeDetails(userModes);
if ( Array.isArray(adminNoFiltering) ) {
if ( adminNoFiltering.includes('-*') ) {
userModes.none.clear();
}
for ( const hn of adminNoFiltering ) {
if ( hn.charAt(0) === '-' ) {
userModes.none.delete(hn.slice(1));
} else {
applyFilteringMode(userModes, hn, 0);
}
}
}
filteringModesToDNR(userModes);
sessionWrite('filteringModeDetails', serializeModeDetails(userModes));
readFilteringModeDetails.cache = userModes;
adminRead('noFiltering').then(results => {
if ( results ) {
localWrite('adminNoFiltering', results);
} else {
localRemove('adminNoFiltering');
}
});
return userModes;
}

View File

@ -19,8 +19,14 @@
Home: https://github.com/gorhill/uBlock
*/
import { browser, dnr, i18n } from './ext.js';
import {
browser,
dnr,
i18n,
} from './ext.js';
import { fetchJSON } from './fetch.js';
import { getAdminRulesets } from './admin.js';
import { ubolLog } from './debug.js';
/******************************************************************************/
@ -460,7 +466,22 @@ async function defaultRulesetsFromLanguage() {
async function enableRulesets(ids) {
const afterIds = new Set(ids);
const beforeIds = new Set(await dnr.getEnabledRulesets());
const [ beforeIds, adminIds, rulesetDetails ] = await Promise.all([
dnr.getEnabledRulesets().then(ids => new Set(ids)),
getAdminRulesets(),
getRulesetDetails(),
]);
for ( const token of adminIds ) {
const c0 = token.charAt(0);
const id = token.slice(1);
if ( c0 === '+' ) {
afterIds.add(id);
} else if ( c0 === '-' ) {
afterIds.delete(id);
}
}
const enableRulesetSet = new Set();
const disableRulesetSet = new Set();
for ( const id of afterIds ) {
@ -472,13 +493,8 @@ async function enableRulesets(ids) {
disableRulesetSet.add(id);
}
if ( enableRulesetSet.size === 0 && disableRulesetSet.size === 0 ) {
return;
}
// Be sure the rulesets to enable/disable do exist in the current version,
// otherwise the API throws.
const rulesetDetails = await getRulesetDetails();
for ( const id of enableRulesetSet ) {
if ( rulesetDetails.has(id) ) { continue; }
enableRulesetSet.delete(id);
@ -487,6 +503,11 @@ async function enableRulesets(ids) {
if ( rulesetDetails.has(id) ) { continue; }
disableRulesetSet.delete(id);
}
if ( enableRulesetSet.size === 0 && disableRulesetSet.size === 0 ) {
return;
}
const enableRulesetIds = Array.from(enableRulesetSet);
const disableRulesetIds = Array.from(disableRulesetSet);

View File

@ -40,6 +40,18 @@ function hashFromIterable(iter) {
return Array.from(iter).sort().join('\n');
}
function isAdminRuleset(listkey) {
const { adminRulesets = [] } = cachedRulesetData;
for ( const id of adminRulesets ) {
const pos = id.indexOf(listkey);
if ( pos === 0 ) { return true; }
if ( pos !== 1 ) { continue; }
const c = id.charAt(0);
if ( c === '+' || c === '-' ) { return true; }
}
return false;
}
/******************************************************************************/
function rulesetStats(rulesetId) {
@ -94,10 +106,13 @@ function renderFilterLists() {
li.title = listStatsTemplate
.replace('{{ruleCount}}', renderNumber(stats.ruleCount))
.replace('{{filterCount}}', renderNumber(stats.filterCount));
const fromAdmin = isAdminRuleset(ruleset.id);
dom.cl.toggle(li, 'fromAdmin', fromAdmin);
const disabled = stats.ruleCount === 0 || fromAdmin;
dom.attr(
qs$(li, '.input.checkbox'),
qs$(li, '.input.checkbox input'),
'disabled',
stats.ruleCount === 0 ? '' : null
disabled ? '' : null
);
dom.cl.remove(li, 'discard');
return li;
@ -358,7 +373,9 @@ async function applyEnabledRulesets() {
const checked = qs$(liEntry, 'input[type="checkbox"]:checked') !== null;
dom.cl.toggle(liEntry, 'checked', checked);
if ( checked === false ) { continue; }
enabledRulesets.push(liEntry.dataset.listkey);
const { listkey } = liEntry.dataset;
if ( isAdminRuleset(listkey) ) { continue; }
enabledRulesets.push(listkey);
}
await sendMessage({
@ -433,9 +450,12 @@ localRead('hideUnusedFilterLists').then(value => {
/******************************************************************************/
const bc = new self.BroadcastChannel('uBOL');
function listen() {
const bc = new self.BroadcastChannel('uBOL');
bc.onmessage = listen.onmessage;
}
bc.onmessage = ev => {
listen.onmessage = ev => {
const message = ev.data;
if ( message instanceof Object === false ) { return; }
const local = cachedRulesetData;
@ -477,6 +497,13 @@ bc.onmessage = ev => {
}
}
if ( message.adminRulesets !== undefined ) {
if ( hashFromIterable(message.adminRulesets) !== hashFromIterable(local.adminRulesets) ) {
local.adminRulesets = message.adminRulesets;
render = true;
}
}
if ( message.enabledRulesets !== undefined ) {
if ( hashFromIterable(message.enabledRulesets) !== hashFromIterable(local.enabledRulesets) ) {
local.enabledRulesets = message.enabledRulesets;
@ -503,6 +530,7 @@ sendMessage({
renderWidgets();
} catch(ex) {
}
listen();
}).catch(reason => {
console.trace(reason);
});

View File

@ -115,7 +115,7 @@ const hostnamesFromMatches = origins => {
/******************************************************************************/
export const broadcastMessage = message => {
const broadcastMessage = message => {
const bc = new self.BroadcastChannel('uBOL');
bc.postMessage(message);
};
@ -123,6 +123,7 @@ export const broadcastMessage = message => {
/******************************************************************************/
export {
broadcastMessage,
parsedURLromOrigin,
toBroaderHostname,
isDescendantHostname,

View File

@ -10,6 +10,12 @@
"disableFirstRunPage": {
"title": "Disable first run page",
"type": "boolean"
},
"rulesets": {
"title": "Rulesets to add/remove",
"description": "Prefix a ruleset id with '+' to add, or '-' to remove. Use '-*' to disable all non-default lists.",
"type": "array",
"items": { "type": "string" }
}
}
}