511 lines
16 KiB
JavaScript
Raw Normal View History

2024-07-24 13:57:40 +00:00
/*******************************************************************************
uBlock Origin Lite - a comprehensive, MV3-compliant content blocker
Copyright (C) 2014-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 { dom, qs$, qsa$ } from './dom.js';
import { browser, localRead, localWrite, sendMessage } from './ext.js';
import { i18n, i18n$ } from './i18n.js';
import punycode from './punycode.js';
/******************************************************************************/
const rulesetMap = new Map();
let cachedRulesetData = {};
let hideUnusedSet = new Set(['regions']);
/******************************************************************************/
function renderNumber(value) {
return value.toLocaleString();
}
function hashFromIterable(iter) {
return Array.from(iter).sort().join('\n');
}
/******************************************************************************/
function rulesetStats(rulesetId) {
const hasOmnipotence = cachedRulesetData.defaultFilteringMode > 1;
const rulesetDetails = rulesetMap.get(rulesetId);
if (rulesetDetails === undefined) { return; }
const { rules, filters } = rulesetDetails;
let ruleCount = rules.plain + rules.regex;
if (hasOmnipotence) {
ruleCount += rules.removeparam + rules.redirect + rules.modifyHeaders;
}
const filterCount = filters.accepted;
return { ruleCount, filterCount };
}
/******************************************************************************/
function renderFilterLists() {
const { enabledRulesets, rulesetDetails } = cachedRulesetData;
const listGroupTemplate = qs$('#templates .groupEntry');
const listEntryTemplate = qs$('#templates .listEntry');
const listStatsTemplate = i18n$('perRulesetStats');
const groupNames = new Map([['user', '']]);
const liFromListEntry = function (ruleset, li, hideUnused) {
if (!li) {
li = dom.clone(listEntryTemplate);
}
const on = enabledRulesets.includes(ruleset.id);
dom.cl.toggle(li, 'checked', on);
dom.cl.toggle(li, 'unused', hideUnused && !on);
qs$(li, 'input[type="checkbox"]').checked = on;
if (dom.attr(li, 'data-listkey') !== ruleset.id) {
dom.attr(li, 'data-listkey', ruleset.id);
qs$(li, '.listname').append(i18n.patchUnicodeFlags(ruleset.name));
dom.cl.remove(li, 'toRemove');
if (ruleset.homeURL) {
dom.cl.add(li, 'support');
dom.attr(qs$(li, 'a.support'), 'href', ruleset.homeURL);
} else {
dom.cl.remove(li, 'support');
}
if (ruleset.instructionURL) {
dom.cl.add(li, 'mustread');
dom.attr(qs$(li, 'a.mustread'), 'href', ruleset.instructionURL);
} else {
dom.cl.remove(li, 'mustread');
}
dom.cl.toggle(li, 'isDefault', ruleset.id === 'default');
}
const stats = rulesetStats(ruleset.id);
li.title = listStatsTemplate
.replace('{{ruleCount}}', renderNumber(stats.ruleCount))
.replace('{{filterCount}}', renderNumber(stats.filterCount));
dom.attr(
qs$(li, '.input.checkbox'),
'disabled',
stats.ruleCount === 0 ? '' : null
);
dom.cl.remove(li, 'discard');
return li;
};
const listEntryCountFromGroup = function (groupRulesets) {
if (Array.isArray(groupRulesets) === false) { return ''; }
let count = 0,
total = 0;
for (const ruleset of groupRulesets) {
if (enabledRulesets.includes(ruleset.id)) {
count += 1;
}
total += 1;
}
return total !== 0 ?
`(${count.toLocaleString()}/${total.toLocaleString()})` :
'';
};
const liFromListGroup = function (groupKey, groupRulesets) {
let liGroup = qs$(`#lists > .groupEntry[data-groupkey="${groupKey}"]`);
if (liGroup === null) {
liGroup = dom.clone(listGroupTemplate);
let groupName = groupNames.get(groupKey);
if (groupName === undefined) {
groupName = i18n$('3pGroup' + groupKey.charAt(0).toUpperCase() + groupKey.slice(1));
groupNames.set(groupKey, groupName);
}
if (groupName !== '') {
dom.text(qs$(liGroup, '.geName'), groupName);
}
}
if (qs$(liGroup, '.geName:empty') === null) {
dom.text(
qs$(liGroup, '.geCount'),
listEntryCountFromGroup(groupRulesets)
);
}
const hideUnused = mustHideUnusedLists(groupKey);
dom.cl.toggle(liGroup, 'hideUnused', hideUnused);
const ulGroup = qs$(liGroup, '.listEntries');
if (!groupRulesets) { return liGroup; }
groupRulesets.sort(function (a, b) {
return (a.name || '').localeCompare(b.name || '');
});
for (let i = 0; i < groupRulesets.length; i++) {
const liEntry = liFromListEntry(
groupRulesets[i],
ulGroup.children[i],
hideUnused
);
if (liEntry.parentElement === null) {
ulGroup.appendChild(liEntry);
}
}
return liGroup;
};
// Visually split the filter lists in groups
const ulLists = qs$('#lists');
const groups = new Map([
[
'default',
rulesetDetails.filter(ruleset =>
ruleset.id === 'default'
),
],
[
'annoyances',
rulesetDetails.filter(ruleset =>
ruleset.group === 'annoyances'
),
],
[
'misc',
rulesetDetails.filter(ruleset =>
ruleset.id !== 'default' &&
ruleset.group === undefined &&
typeof ruleset.lang !== 'string'
),
],
[
'regions',
rulesetDetails.filter(ruleset =>
typeof ruleset.lang === 'string'
),
],
]);
dom.cl.toggle(dom.body, 'hideUnused', mustHideUnusedLists('*'));
for (const [groupKey, groupRulesets] of groups) {
const liGroup = liFromListGroup(groupKey, groupRulesets);
dom.attr(liGroup, 'data-groupkey', groupKey);
if (liGroup.parentElement === null) {
ulLists.appendChild(liGroup);
}
}
}
/******************************************************************************/
function renderWidgets() {
if (cachedRulesetData.firstRun) {
dom.cl.add(dom.body, 'firstRun');
}
renderDefaultMode();
renderTrustedSites();
qs$('#autoReload input[type="checkbox"]').checked = cachedRulesetData.autoReload;
{
const input = qs$('#showBlockedCount input[type="checkbox"]');
if (cachedRulesetData.canShowBlockedCount) {
input.checked = cachedRulesetData.showBlockedCount;
} else {
input.checked = false;
dom.attr(input, 'disabled', '');
}
}
// Compute total counts
let rulesetCount = 0;
let filterCount = 0;
let ruleCount = 0;
for (const liEntry of qsa$('#lists .listEntry[data-listkey]')) {
if (qs$(liEntry, 'input[type="checkbox"]:checked') === null) { continue; }
rulesetCount += 1;
const stats = rulesetStats(liEntry.dataset.listkey);
if (stats === undefined) { continue; }
ruleCount += stats.ruleCount;
filterCount += stats.filterCount;
}
dom.text('#listsOfBlockedHostsPrompt', i18n$('perRulesetStats')
.replace('{{ruleCount}}', ruleCount.toLocaleString())
.replace('{{filterCount}}', filterCount.toLocaleString())
);
dom.cl.toggle(dom.body, 'noMoreRuleset',
rulesetCount === cachedRulesetData.maxNumberOfEnabledRulesets
);
}
/******************************************************************************/
function renderDefaultMode() {
const defaultLevel = cachedRulesetData.defaultFilteringMode;
if (defaultLevel !== 0) {
qs$(`.filteringModeCard input[type="radio"][value="${defaultLevel}"]`).checked = true;
} else {
dom.prop('.filteringModeCard input[type="radio"]', 'checked', false);
}
}
/******************************************************************************/
async function onFilteringModeChange(ev) {
const input = ev.target;
const newLevel = parseInt(input.value, 10);
switch (newLevel) {
case 1: { // Revoke broad permissions
browser.permissions.remove({
origins: ['<all_urls>']
});
cachedRulesetData.defaultFilteringMode = 1;
break;
}
case 2:
case 3: { // Request broad permissions
const granted = browser.permissions.request({
origins: ['<all_urls>']
});
if (granted) {
const actualLevel = sendMessage({
what: 'setDefaultFilteringMode',
level: newLevel,
});
cachedRulesetData.defaultFilteringMode = actualLevel;
}
break;
}
default:
break;
}
renderFilterLists();
renderWidgets();
}
dom.on(
'#defaultFilteringMode',
'change',
'.filteringModeCard input[type="radio"]',
ev => { onFilteringModeChange(ev); }
);
/******************************************************************************/
dom.on('#autoReload input[type="checkbox"]', 'change', ev => {
sendMessage({
what: 'setAutoReload',
state: ev.target.checked,
});
});
dom.on('#showBlockedCount input[type="checkbox"]', 'change', ev => {
sendMessage({
what: 'setShowBlockedCount',
state: ev.target.checked,
});
});
/******************************************************************************/
function renderTrustedSites() {
const textarea = qs$('#trustedSites');
const hostnames = cachedRulesetData.trustedSites;
textarea.value = hostnames.map(hn => punycode.toUnicode(hn)).join('\n');
if (textarea.value !== '') {
textarea.value += '\n';
}
}
function changeTrustedSites() {
const hostnames = getStagedTrustedSites();
const hash = hashFromIterable(cachedRulesetData.trustedSites);
if (hashFromIterable(hostnames) === hash) { return; }
sendMessage({
what: 'setTrustedSites',
hostnames,
});
}
function getStagedTrustedSites() {
const textarea = qs$('#trustedSites');
return textarea.value.split(/\s/).map(hn => {
try {
return punycode.toASCII(
(new URL(`https://${hn}/`)).hostname
);
} catch (_) {
}
return '';
}).filter(hn => hn !== '');
}
dom.on('#trustedSites', 'blur', changeTrustedSites);
self.addEventListener('beforeunload', changeTrustedSites);
/******************************************************************************/
async function applyEnabledRulesets() {
const enabledRulesets = [];
for (const liEntry of qsa$('#lists .listEntry[data-listkey]')) {
const checked = qs$(liEntry, 'input[type="checkbox"]:checked') !== null;
dom.cl.toggle(liEntry, 'checked', checked);
if (checked === false) { continue; }
enabledRulesets.push(liEntry.dataset.listkey);
}
sendMessage({
what: 'applyRulesets',
enabledRulesets,
});
renderWidgets();
}
dom.on('#lists', 'change', '.listEntry input[type="checkbox"]', () => {
applyEnabledRulesets();
});
/******************************************************************************/
// Collapsing of unused lists.
function mustHideUnusedLists(which) {
const hideAll = hideUnusedSet.has('*');
if (which === '*') { return hideAll; }
return hideUnusedSet.has(which) !== hideAll;
}
function toggleHideUnusedLists(which) {
const doesHideAll = hideUnusedSet.has('*');
let groupSelector;
let mustHide;
if (which === '*') {
mustHide = doesHideAll === false;
groupSelector = '';
hideUnusedSet.clear();
if (mustHide) {
hideUnusedSet.add(which);
}
dom.cl.toggle(dom.body, 'hideUnused', mustHide);
dom.cl.toggle('.groupEntry[data-groupkey]', 'hideUnused', mustHide);
} else {
const doesHide = hideUnusedSet.has(which);
if (doesHide) {
hideUnusedSet.delete(which);
} else {
hideUnusedSet.add(which);
}
mustHide = doesHide === doesHideAll;
groupSelector = `.groupEntry[data-groupkey="${which}"]`;
dom.cl.toggle(groupSelector, 'hideUnused', mustHide);
}
for (const elem of qsa$(`#lists ${groupSelector} .listEntry[data-listkey] input[type="checkbox"]:not(:checked)`)) {
dom.cl.toggle(
elem.closest('.listEntry[data-listkey]'),
'unused',
mustHide
);
}
localWrite('hideUnusedFilterLists', Array.from(hideUnusedSet));
}
dom.on('#lists', 'click', '.groupEntry[data-groupkey] > .geDetails', ev => {
toggleHideUnusedLists(
dom.attr(ev.target.closest('[data-groupkey]'), 'data-groupkey')
);
});
// Initialize from saved state.
localRead('hideUnusedFilterLists').then(value => {
if (Array.isArray(value) === false) { return; }
hideUnusedSet = new Set(value);
});
/******************************************************************************/
const bc = new self.BroadcastChannel('uBOL');
bc.onmessage = ev => {
const message = ev.data;
if (message instanceof Object === false) { return; }
const local = cachedRulesetData;
let render = false;
// Keep added sites which have not yet been committed
if (message.trustedSites !== undefined) {
if (hashFromIterable(message.trustedSites) !== hashFromIterable(local.trustedSites)) {
const current = new Set(local.trustedSites);
const staged = new Set(getStagedTrustedSites());
for (const hn of staged) {
if (current.has(hn) === false) { continue; }
staged.delete(hn);
}
const combined = Array.from(new Set([...message.trustedSites, ...staged]));
local.trustedSites = combined;
render = true;
}
}
if (message.defaultFilteringMode !== undefined) {
if (message.defaultFilteringMode !== local.defaultFilteringMode) {
local.defaultFilteringMode = message.defaultFilteringMode;
render = true;
}
}
if (message.autoReload !== undefined) {
if (message.autoReload !== local.autoReload) {
local.autoReload = message.autoReload;
render = true;
}
}
if (message.showBlockedCount !== undefined) {
if (message.showBlockedCount !== local.showBlockedCount) {
local.showBlockedCount = message.showBlockedCount;
render = true;
}
}
if (message.enabledRulesets !== undefined) {
if (hashFromIterable(message.enabledRulesets) !== hashFromIterable(local.enabledRulesets)) {
local.enabledRulesets = message.enabledRulesets;
render = true;
}
}
if (render === false) { return; }
renderFilterLists();
renderWidgets();
};
/******************************************************************************/
sendMessage({
what: 'getOptionsPageData',
}).then(data => {
if (!data) { return; }
cachedRulesetData = data;
rulesetMap.clear();
cachedRulesetData.rulesetDetails.forEach(rule => rulesetMap.set(rule.id, rule));
try {
renderFilterLists();
renderWidgets();
} catch (ex) {
}
}).catch(reason => {
console.trace(reason);
});
/******************************************************************************/