diff --git a/platform/mv3/extension/3p-filters.html b/platform/mv3/extension/3p-filters.html deleted file mode 100644 index 42a260862..000000000 --- a/platform/mv3/extension/3p-filters.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - -uBlock Origin Lite — Filter lists - - - - - - - - - -
- -
-

- - _ -

-
-

-

-

- -
-
-
- -
- -
-
-
-
-
-
- -
-
- - - - - - - - diff --git a/platform/mv3/extension/_locales/en/messages.json b/platform/mv3/extension/_locales/en/messages.json index 5b7b29ecb..7fed0591d 100644 --- a/platform/mv3/extension/_locales/en/messages.json +++ b/platform/mv3/extension/_locales/en/messages.json @@ -222,5 +222,57 @@ "genericRevert": { "message": "Revert", "description": "for generic 'Revert' buttons" + }, + "firstRunSectionLabel": { + "message": "Welcome", + "description": "The header text for the welcome message section" + }, + "firstRunDescription": { + "message": "You have just installed uBO Lite. You can choose here the default filtering mode to use on all websites.\n\nBy default, Basic mode is selected because it does not require the permission to read and change data. If you trust uBO Lite, you can give it broad permission to read and change data on all websites in order to enable more advanced filtering capabilities for all websites by default.", + "description": "Descriptive text shown at first install time only " + }, + "defaultFilteringModeSectionLabel": { + "message": "Default filtering mode", + "description": "The header text for the default filtering mode section" + }, + "defaultFilteringModeDescription": { + "message": "The default filtering mode will be overriden by per-website filtering modes. You can adjust the filtering mode on any given website according to whichever mode works best on that website. Each mode has its advantages and disadvantages.", + "description": "This describes the default filtering mode setting" + }, + "filteringMode0Name": { + "message": "no filtering", + "description": "Name of blocking mode 0" + }, + "filteringMode1Name": { + "message": "basic", + "description": "Name of blocking mode 1" + }, + "filteringMode2Name": { + "message": "optimal", + "description": "Name of blocking mode 2" + }, + "filteringMode3Name": { + "message": "complete", + "description": "Name of blocking mode 3" + }, + "basicFilteringModeDescription": { + "message": "Basic network filtering from selected filter lists.\n\nDoes not require permission to read and change data on websites.", + "description": "This describes the 'basic' filtering mode" + }, + "optimalFilteringModeDescription": { + "message": "Advanced network filtering plus specific extended filtering from selected filter lists.\n\nRequires broad permission to read and change data on all websites.", + "description": "This describes the 'optimal' filtering mode" + }, + "completeFilteringModeDescription": { + "message": "Advanced network filtering plus specific and generic extended filtering from selected filter lists.\n\nRequires broad permission to read and change data on all websites.\n\nGeneric extended filtering may cause higher webpage resources usage.", + "description": "This describes the 'complete' filtering mode" + }, + "behaviorSectionLabel": { + "message": "Behavior", + "description": "The header text for the 'Behavior' section" + }, + "autoReloadLabel": { + "message": "Automatically reload page when changing filtering mode", + "description": "Label for a checkbox in the options page" } } diff --git a/platform/mv3/extension/css/dashboard-common.css b/platform/mv3/extension/css/dashboard-common.css index da2c05226..47852e7d6 100644 --- a/platform/mv3/extension/css/dashboard-common.css +++ b/platform/mv3/extension/css/dashboard-common.css @@ -1,5 +1,4 @@ -body > div.body { - margin: 0 1em; +body { } h2, h3 { margin: 1em 0; diff --git a/platform/mv3/extension/css/dashboard.css b/platform/mv3/extension/css/dashboard.css index c714ef810..271557958 100644 --- a/platform/mv3/extension/css/dashboard.css +++ b/platform/mv3/extension/css/dashboard.css @@ -1,9 +1,11 @@ html, body { align-items: center; + box-sizing: border-box; display: flex; flex-direction: column; height: 100vh; justify-content: stretch; + padding: 0 1em; overflow: hidden; position: relative; width: 100vw; diff --git a/platform/mv3/extension/css/filtering-mode.css b/platform/mv3/extension/css/filtering-mode.css new file mode 100644 index 000000000..fecb1ac3c --- /dev/null +++ b/platform/mv3/extension/css/filtering-mode.css @@ -0,0 +1,92 @@ +.filteringModeSlider { + align-items: center; + display: flex; + height: 60px; + justify-content: center; + position: relative; + width: 240px; + } + +.filteringModeButton { + background-color: var(--surface-1); + box-sizing: border-box; + border-radius: 30% 15% / 15% 30%; + height: 100%; + position: absolute; + width: 25%; + z-index: 10; + } + +.filteringModeButton > div { + background-color: var(--accent-surface-1); + border: 4px solid var(--accent-surface-1); + border-radius: inherit; + box-sizing: border-box; + height: calc(100% - 2px); + margin: 1px; + width: calc(100% - 2px); + } + +.filteringModeSlider.moving .filteringModeButton > div, +.filteringModeButton > div:hover { + filter: brightness(0.9); + } + +.filteringModeSlider[data-level="0"] .filteringModeButton > div { + background-color: var(--surface-2); + border-color: var(--surface-2); + } + +.filteringModeSlider span[data-level] { + background-color: var(--accent-surface-1); + display: inline-flex; + height: 30%; + margin-left: 1px; + width: 25%; + } + +.filteringModeSlider.moving span[data-level] { + pointer-events: none; + } + +.filteringModeSlider[data-level="0"] .filteringModeButton { + left: 0; + } +.filteringModeSlider[data-level="1"] .filteringModeButton { + left: 25%; + } +.filteringModeSlider[data-level="2"] .filteringModeButton { + left: 50%; + } +.filteringModeSlider[data-level="3"] .filteringModeButton { + left: 75%; + } +[dir="rtl"] .filteringModeSlider[data-level="0"] .filteringModeButton { + left: 75%; + } +[dir="rtl"] .filteringModeSlider[data-level="1"] .filteringModeButton { + left: 50%; + } +[dir="rtl"] .filteringModeSlider[data-level="2"] .filteringModeButton { + left: 25%; + } +[dir="rtl"] .filteringModeSlider[data-level="3"] .filteringModeButton { + left: 0; + } + + +.filteringModeSlider[data-level="0"] span[data-level] { + background-color: var(--surface-2); + } + +.filteringModeSlider[data-level="1"] span[data-level]:nth-of-type(1) ~ span[data-level] { + background-color: var(--surface-2); + } + +.filteringModeSlider[data-level="2"] span[data-level]:nth-of-type(2) ~ span[data-level] { + background-color: var(--surface-2); + } + +.filteringModeSlider[data-level]:not(.moving) span[data-level]:hover { + filter: brightness(0.9); + } diff --git a/platform/mv3/extension/css/popup.css b/platform/mv3/extension/css/popup.css index 20804f366..5a5209cda 100644 --- a/platform/mv3/extension/css/popup.css +++ b/platform/mv3/extension/css/popup.css @@ -47,34 +47,32 @@ hr { padding: 0; } -#sticky { - background-color: var(--surface-1); - position: sticky; - top: 0; - z-index: 100; - } -#stickyTools { - align-items: stretch; +#filteringModeText { + background-color: var(--surface-2); + color: var(--ink-3); display: flex; - justify-content: space-between; + padding: var(--default-gap-xsmall); + text-transform: lowercase; } -#switch { - color: var(--popup-power-ink); - cursor: pointer; - display: flex; - fill: var(--popup-power-ink); - flex-grow: 1; - font-size: 96px; - justify-content: center; - margin: var(--popup-gap-thin) var(--popup-gap-thin) 0; - padding: 0; - stroke: none; - stroke-width: 64; +#filteringModeText > span:nth-of-type(2) { + display: none; } -body.off #switch { - fill: var(--surface-1); - stroke: var(--checkbox-ink); +#filteringModeText > span:nth-of-type(2):not(:empty) { + display: inline; } +#filteringModeText > span:nth-of-type(2):not(:empty)::before { + content: '\2002\2192\2002'; + } +[dir="rtl"] #filteringModeText > span:nth-of-type(2):not(:empty)::before { + content: '\2002\2190\2002'; + } + +.filteringModeSlider { + height: 32px; + margin: 8px; + width: 128px; + } + .rulesetTools { background-color: transparent; border: 0; @@ -100,9 +98,7 @@ body.off #switch { .rulesetTools [id] > svg { fill: var(--ink-4); } -body.needReload #refresh, -body.needSave #saveRules, -body.needSave #revertRules { +body.needReload #refresh { visibility: visible; } #hostname { @@ -184,32 +180,6 @@ body.mobile.no-tooltips .toolRibbon .tool { margin-bottom: 0; } -#toggleGreatPowers { - position: relative; - } -body.hasOmnipotence #toggleGreatPowers { - pointer-events: none; - } -#toggleGreatPowers .badge { - bottom: 4px; - font-size: var(--font-size-xsmall); - line-height: 1; - pointer-events: none; - position: absolute; - right: 4px; - } -body:not(.hasGreatPowers) [data-i18n-title="popupGrantGreatPowers"], -body.hasGreatPowers [data-i18n-title="popupRevokeGreatPowers"] { - display: flex; - } -body:not(.hasGreatPowers) [data-i18n-title="popupRevokeGreatPowers"], -body.hasGreatPowers [data-i18n-title="popupGrantGreatPowers"] { - display: none; - } -body:not(.hasOmnipotence) [data-i18n-title="popupRevokeGreatPowers"] { - fill: var(--popup-power-ink); - } - #moreOrLess { column-gap: 0; display: grid; @@ -281,22 +251,12 @@ body:not([data-section~="b"]) [data-section="b"] { :root.desktop body { --popup-gap: calc(var(--font-size) * 0.875); } -:root.desktop body:not(.off) #switch:hover { - fill: rgb(var(--popup-power-ink-rgb) / 90%); - } -:root.desktop body.off #switch:hover { - stroke: var(--popup-power-ink); - } :root.desktop .rulesetTools [id]:hover { background-color: var(--popup-ruleset-tool-surface-hover); } :root.desktop .rulesetTools [id]:hover > svg { fill: var(--ink-2); } -:root.desktop #firewall { - direction: rtl; - line-height: 1.4; - } :root.desktop .tool:hover { background-color: var(--popup-toolbar-surface-hover); } diff --git a/platform/mv3/extension/css/3p-filters.css b/platform/mv3/extension/css/settings.css similarity index 62% rename from platform/mv3/extension/css/3p-filters.css rename to platform/mv3/extension/css/settings.css index 570c03923..e04300ca1 100644 --- a/platform/mv3/extension/css/3p-filters.css +++ b/platform/mv3/extension/css/settings.css @@ -10,25 +10,66 @@ legend { font-size: var(--font-size-smaller); padding: var(--default-gap-xxsmall); } -#actions { - background-color: var(--surface-1); - padding: 1px 0; - position: sticky; - top: 0; - z-index: 10; +body .firstRun { + display: none; } body.firstRun .firstRun { background-color: rgb(var(--dashboard-highlight-surface-rgb)); + display: block; padding: 8px; - zoom: 1.1; } -#buttonUpdate.active { +body > div { + margin: 1em 0; + } +h3 { + margin: 0; + } +p { + white-space: pre-line; + } + +#defaultFilteringMode { + display: grid; + gap: 1em; + grid: auto-flow dense / 1fr 1fr 1fr; + } +.filteringModeCard { + border: 1px solid var(--surface-3); + border-radius: 4px; + display: flex; + flex-direction: column; + } +.filteringModeCard:has(.radio > [type="radio"]:checked) { + background-color: var(--surface-0); + } +.filteringModeCard .input.radio ~ [data-i18n] { + text-transform: capitalize; + } +.filteringModeCard span:has(> .input) { + align-items: center; + display: inline-flex; + } +.filteringModeCard > div { + align-items: center; + box-sizing: border-box; + display: flex; + padding: 0.5em; + width: 100%; + } +.filteringModeCard > div:nth-of-type(2) { + justify-content: center; + } +.filteringModeCard > div:nth-of-type(3) { + border-top: 1px solid var(--surface-2); + font-size: var(--font-size-smaller); + white-space: pre-line; + } +.filteringModeSlider { + height: calc(60px / 2); pointer-events: none; + width: calc(240px / 2); } -#buttonUpdate.active .fa-icon svg { - animation: spin 1s linear infinite; - transform-origin: 50%; - } + #lists { margin: 0.5em 0 0 0; padding: 0; @@ -74,15 +115,8 @@ body.firstRun .firstRun { .listEntry .listname { white-space: nowrap; } -.listEntry.toRemove .checkbox { - visibility: hidden; - } -.listEntry.toRemove .listname { - text-decoration: line-through; - } .listEntry a, -.listEntry .fa-icon, -.listEntry .counts { +.listEntry .fa-icon { color: var(--info0-ink); fill: var(--info0-ink); display: none; @@ -101,75 +135,15 @@ body.firstRun .firstRun { .listEntry.support a.support { display: inline-flex; } -.listEntry .remove, -.listEntry .unsecure, -.listEntry .failed { - color: var(--info3-ink); - fill: var(--info3-ink); - cursor: pointer; - } -.listEntry.external .remove { - display: inline-flex; - } .listEntry.mustread a.mustread { color: var(--info1-ink); fill: var(--info1-ink); display: inline-flex; } -.listEntry .counts { - font-size: smaller; -} -.listEntry.checked .counts { - display: inline-block; -} .listEntry .status { cursor: default; display: none; } -.listEntry.checked.unsecure .unsecure { - display: inline-flex; - } -.listEntry.failed .failed { - display: inline-flex; - } -.listEntry .cache { - cursor: pointer; - } -.listEntry.checked.cached:not(.obsolete) .cache { - display: inline-flex; - } -.listEntry .obsolete { - color: var(--info2-ink); - fill: var(--info2-ink); - } -body:not(.updating) .listEntry.checked.obsolete .obsolete { - display: inline-flex; - } -.listEntry .updating { - transform-origin: 50%; - } -body.updating .listEntry.checked.obsolete .updating { - animation: spin 1s steps(8) infinite; - display: inline-flex; - } -.listEntry.toImport { - margin: 0.5em 0; - } -.listEntry.toImport textarea { - border: 1px solid #ccc; - box-sizing: border-box; - display: block; - font-size: smaller; - height: 6em; - margin: 0; - resize: vertical; - visibility: hidden; - white-space: pre; - width: 100%; - } -.listEntry.toImport.checked textarea { - visibility: visible; - } /* touch-screen devices */ :root.mobile .listEntry .fa-icon { @@ -181,7 +155,6 @@ body.updating .listEntry.checked.obsolete .updating { -webkit-margin-start: 0; } :root.mobile .li.listEntry { - /* background-color: var(--bg-1); */ overflow-x: auto; } :root.mobile .li.listEntry label > span:not([class]) { diff --git a/platform/mv3/extension/dashboard.html b/platform/mv3/extension/dashboard.html index 621fd850b..4f446dee3 100644 --- a/platform/mv3/extension/dashboard.html +++ b/platform/mv3/extension/dashboard.html @@ -15,7 +15,7 @@
diff --git a/platform/mv3/extension/js/background.js b/platform/mv3/extension/js/background.js index 0d2866afa..4debbb2cb 100644 --- a/platform/mv3/extension/js/background.js +++ b/platform/mv3/extension/js/background.js @@ -47,15 +47,19 @@ import { } from './scripting-manager.js'; import { - matchesTrustedSiteDirective, - toggleTrustedSiteDirective, -} from './trusted-sites.js'; + getFilteringMode, + setFilteringMode, + getDefaultFilteringMode, + setDefaultFilteringMode, + syncWithDemotedOrigins, +} from './mode-manager.js'; /******************************************************************************/ const rulesetConfig = { version: '', - enabledRulesets: [], + enabledRulesets: [ 'default' ], + autoReload: 1, firstRun: false, }; @@ -73,12 +77,25 @@ async function loadRulesetConfig() { rulesetConfig.firstRun = true; return; } + let rawConfig; + try { + rawConfig = JSON.parse(self.atob(configRule.condition.urlFilter)); + } catch(ex) { + } + // New format + if ( Array.isArray(rawConfig) ) { + rulesetConfig.version = rawConfig[0]; + rulesetConfig.enabledRulesets = rawConfig[1]; + rulesetConfig.autoReload = rawConfig[2]; + return; + } + + // Legacy format. TODO: remove when next new format is widely in use. const match = /^\|\|(?:example|ubolite)\.invalid\/([^\/]+)\/(?:([^\/]+)\/)?/.exec( configRule.condition.urlFilter ); if ( match === null ) { return; } - rulesetConfig.version = match[1]; if ( match[2] ) { rulesetConfig.enabledRulesets = @@ -97,13 +114,21 @@ async function saveRulesetConfig() { }, condition: { urlFilter: '', + initiatorDomains: [ + 'ubolite.invalid', + ], + resourceTypes: [ + 'main_frame', + ], }, }; } - - const version = rulesetConfig.version; - const enabledRulesets = encodeURIComponent(rulesetConfig.enabledRulesets.join(' ')); - const urlFilter = `||ubolite.invalid/${version}/${enabledRulesets}/`; + const rawConfig = [ + rulesetConfig.version, + rulesetConfig.enabledRulesets, + rulesetConfig.autoReload, + ]; + const urlFilter = self.btoa(JSON.stringify(rawConfig)); if ( urlFilter === configRule.condition.urlFilter ) { return; } configRule.condition.urlFilter = urlFilter; @@ -128,18 +153,13 @@ function hasOmnipotence() { }); } -function onPermissionsAdded(permissions) { - if ( permissions.origins?.includes('') ) { - updateDynamicRules(); - } - registerInjectables(permissions.origins); -} - function onPermissionsRemoved(permissions) { if ( permissions.origins?.includes('') ) { updateDynamicRules(); } - registerInjectables(permissions.origins); + syncWithDemotedOrigins(permissions.origins).then(( ) => { + registerInjectables(permissions.origins); + }); } /******************************************************************************/ @@ -158,17 +178,22 @@ function onMessage(request, sender, callback) { return true; } - case 'getRulesetData': { + case 'getOptionsPageData': { Promise.all([ + getDefaultFilteringMode(), getRulesetDetails(), dnr.getEnabledRulesets(), - hasOmnipotence(), ]).then(results => { - const [ rulesetDetails, enabledRulesets, hasOmnipotence ] = results; + const [ + defaultFilteringMode, + rulesetDetails, + enabledRulesets, + ] = results; callback({ + defaultFilteringMode, enabledRulesets, rulesetDetails: Array.from(rulesetDetails.values()), - hasOmnipotence, + autoReload: rulesetConfig.autoReload === 1, firstRun: rulesetConfig.firstRun, }); rulesetConfig.firstRun = false; @@ -176,16 +201,24 @@ function onMessage(request, sender, callback) { return true; } + case 'setAutoReload': + rulesetConfig.autoReload = request.state ? 1 : 0; + saveRulesetConfig().then(( ) => { + callback(); + }); + return true; + case 'popupPanelData': { Promise.all([ - matchesTrustedSiteDirective(request), + getFilteringMode(request.hostname), hasOmnipotence(), hasGreatPowers(request.origin), getEnabledRulesetsDetails(), getInjectableCount(request.origin), ]).then(results => { callback({ - isTrusted: results[0], + level: results[0], + autoReload: rulesetConfig.autoReload === 1, hasOmnipotence: results[1], hasGreatPowers: results[2], rulesetDetails: results[3], @@ -195,17 +228,44 @@ function onMessage(request, sender, callback) { return true; } - case 'toggleTrustedSiteDirective': { - toggleTrustedSiteDirective(request).then(response => { + case 'getFilteringMode': { + getFilteringMode(request.hostname).then(actualLevel => { + callback(actualLevel); + }); + return true; + } + + case 'setFilteringMode': { + getFilteringMode(request.hostname).then(actualLevel => { + if ( request.level === actualLevel ) { return actualLevel; } + return setFilteringMode(request.hostname, request.level); + }).then(actualLevel => { registerInjectables(); - callback(response); + callback(actualLevel); + }); + return true; + } + + case 'setDefaultFilteringMode': { + getDefaultFilteringMode( + ).then(beforeLevel => + setDefaultFilteringMode(request.level).then(afterLevel => + ({ beforeLevel, afterLevel }) + ) + ).then(({ beforeLevel, afterLevel }) => { + if ( beforeLevel === 1 || afterLevel === 1 ) { + updateDynamicRules(); + } + if ( afterLevel !== beforeLevel ) { + registerInjectables(); + } + callback(afterLevel); }); return true; } default: break; - } } @@ -245,7 +305,6 @@ async function start() { runtime.onMessage.addListener(onMessage); - browser.permissions.onAdded.addListener(onPermissionsAdded); browser.permissions.onRemoved.addListener(onPermissionsRemoved); if ( rulesetConfig.firstRun ) { diff --git a/platform/mv3/extension/js/dashboard.js b/platform/mv3/extension/js/dashboard.js index 8291aa096..f21fc9961 100644 --- a/platform/mv3/extension/js/dashboard.js +++ b/platform/mv3/extension/js/dashboard.js @@ -115,7 +115,7 @@ if ( self.location.hash.slice(1) === 'no-dashboard.html' ) { if ( self.location.hash !== '' ) { pane = self.location.hash.slice(1) || null; } - loadDashboardPanel(pane !== null ? pane : '3p-filters.html', true); + loadDashboardPanel(pane !== null ? pane : 'settings.html', true); dom.on( qs$('#dashboard-nav'), diff --git a/platform/mv3/extension/js/mode-manager.js b/platform/mv3/extension/js/mode-manager.js new file mode 100644 index 000000000..0b61c4667 --- /dev/null +++ b/platform/mv3/extension/js/mode-manager.js @@ -0,0 +1,366 @@ +/******************************************************************************* + + uBlock Origin - a browser extension to block requests. + 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 { dnr } from './ext.js'; + +import { + hostnamesFromMatches, + isDescendantHostnameOfIter, +} from './utils.js'; + +import { + TRUSTED_DIRECTIVE_BASE_RULE_ID, + BLOCKING_MODES_RULE_ID, + getDynamicRules +} from './ruleset-manager.js'; + +/******************************************************************************/ + +const pruneDescendantHostnamesFromSet = (hostname, hnSet) => { + for ( const hn of hnSet ) { + if ( hn.endsWith(hostname) === false ) { continue; } + if ( hn === hostname ) { continue; } + if ( hn.at(-hostname.length-1) !== '.' ) { continue; } + hnSet.delete(hn); + } +}; + +/******************************************************************************/ + +const eqSets = (setBefore, setAfter) => { + for ( const hn of setAfter ) { + if ( setBefore.has(hn) === false ) { return false; } + } + for ( const hn of setBefore ) { + if ( setAfter.has(hn) === false ) { return false; } + } + return true; +}; + +/******************************************************************************/ + +// 0: no blocking => TRUSTED_DIRECTIVE_BASE_RULE_ID / requestDomains +// 1: network => BLOCKING_MODES_RULE_ID / excludedInitiatorDomains +// 2: specific content => BLOCKING_MODES_RULE_ID / excludedRequestDomains +// 3: generic content => BLOCKING_MODES_RULE_ID / initiatorDomains + +let filteringModeDetailsPromise; + +function getActualFilteringModeDetails() { + if ( filteringModeDetailsPromise !== undefined ) { + return filteringModeDetailsPromise; + } + filteringModeDetailsPromise = Promise.all([ + getDynamicRules(), + getAllTrustedSiteDirectives(), + ]).then(results => { + const [ dynamicRuleMap, trustedSiteDirectives ] = results; + const details = { + none: new Set(trustedSiteDirectives), + }; + const rule = dynamicRuleMap.get(BLOCKING_MODES_RULE_ID); + if ( rule ) { + details.network = new Set(rule.condition.excludedInitiatorDomains); + details.extendedSpecific = new Set(rule.condition.excludedRequestDomains); + details.extendedGeneric = new Set(rule.condition.initiatorDomains); + } else { + details.network = new Set([ 'all-urls' ]); + details.extendedSpecific = new Set(); + details.extendedGeneric = new Set(); + } + return details; + }); + return filteringModeDetailsPromise; +} + +/******************************************************************************/ + +async function getFilteringModeDetails() { + const actualDetails = await getActualFilteringModeDetails(); + return { + none: new Set(actualDetails.none), + network: new Set(actualDetails.network), + extendedSpecific: new Set(actualDetails.extendedSpecific), + extendedGeneric: new Set(actualDetails.extendedGeneric), + }; +} + +/******************************************************************************/ + +async function setFilteringModeDetails(afterDetails) { + const [ dynamicRuleMap, actualDetails ] = await Promise.all([ + getDynamicRules(), + getActualFilteringModeDetails(), + ]); + const addRules = []; + const removeRuleIds = []; + if ( eqSets(actualDetails.none, afterDetails.none) === false ) { + actualDetails.none = afterDetails.none; + if ( dynamicRuleMap.has(TRUSTED_DIRECTIVE_BASE_RULE_ID) ) { + removeRuleIds.push(TRUSTED_DIRECTIVE_BASE_RULE_ID); + dynamicRuleMap.delete(TRUSTED_DIRECTIVE_BASE_RULE_ID); + } + const rule = { + id: TRUSTED_DIRECTIVE_BASE_RULE_ID, + action: { type: 'allowAllRequests' }, + condition: { + requestDomains: [], + resourceTypes: [ 'main_frame' ], + }, + }; + if ( actualDetails.none.size ) { + rule.condition.requestDomains = Array.from(actualDetails.none); + addRules.push(rule); + dynamicRuleMap.set(TRUSTED_DIRECTIVE_BASE_RULE_ID, rule); + } + } + if ( + eqSets(actualDetails.network, afterDetails.network) === false || + eqSets(actualDetails.extendedSpecific, afterDetails.extendedSpecific) === false || + eqSets(actualDetails.extendedGeneric, afterDetails.extendedGeneric) === false + ) { + actualDetails.network = afterDetails.network; + actualDetails.extendedSpecific = afterDetails.extendedSpecific; + actualDetails.extendedGeneric = afterDetails.extendedGeneric; + if ( dynamicRuleMap.has(BLOCKING_MODES_RULE_ID) ) { + removeRuleIds.push(BLOCKING_MODES_RULE_ID); + dynamicRuleMap.delete(BLOCKING_MODES_RULE_ID); + } + const rule = { + id: BLOCKING_MODES_RULE_ID, + action: { type: 'allow' }, + condition: { + resourceTypes: [ 'main_frame' ], + urlFilter: '||ubol-blocking-modes.invalid^', + }, + }; + if ( actualDetails.network.size ) { + rule.condition.excludedInitiatorDomains = + Array.from(actualDetails.network); + } + if ( actualDetails.extendedSpecific.size ) { + rule.condition.excludedRequestDomains = + Array.from(actualDetails.extendedSpecific); + } + if ( actualDetails.extendedGeneric.size ) { + rule.condition.initiatorDomains = + Array.from(actualDetails.extendedGeneric); + } + if ( + actualDetails.network.size || + actualDetails.extendedSpecific.size || + actualDetails.extendedGeneric.size + ) { + addRules.push(rule); + dynamicRuleMap.set(BLOCKING_MODES_RULE_ID, rule); + } + } + if ( addRules.length === 0 && removeRuleIds.length === 0 ) { return; } + const updateOptions = {}; + if ( addRules.length ) { + updateOptions.addRules = addRules; + } + if ( removeRuleIds.length ) { + updateOptions.removeRuleIds = removeRuleIds; + } + return dnr.updateDynamicRules(updateOptions); +} + +/******************************************************************************/ + +async function getFilteringMode(hostname) { + const filteringModes = await getFilteringModeDetails(); + if ( filteringModes.none.has(hostname) ) { return 0; } + if ( filteringModes.network.has(hostname) ) { return 1; } + if ( filteringModes.extendedSpecific.has(hostname) ) { return 2; } + if ( filteringModes.extendedGeneric.has(hostname) ) { return 3; } + return getDefaultFilteringMode(); +} + +/******************************************************************************/ + +async function setFilteringMode(hostname, afterLevel) { + if ( hostname === 'all-urls' ) { + return setDefaultFilteringMode(afterLevel); + } + const [ + beforeLevel, + defaultLevel, + filteringModes + ] = await Promise.all([ + getFilteringMode(hostname), + getDefaultFilteringMode(), + getFilteringModeDetails(), + ]); + if ( afterLevel === beforeLevel ) { return afterLevel; } + const { + none, + network, + extendedSpecific, + extendedGeneric, + } = filteringModes; + switch ( beforeLevel ) { + case 0: + none.delete(hostname); + break; + case 1: + network.delete(hostname); + break; + case 2: + extendedSpecific.delete(hostname); + break; + case 3: + extendedGeneric.delete(hostname); + break; + } + if ( afterLevel !== defaultLevel ) { + switch ( afterLevel ) { + case 0: + if ( isDescendantHostnameOfIter(hostname, none) === false ) { + filteringModes.none.add(hostname); + pruneDescendantHostnamesFromSet(hostname, none); + } + break; + case 1: + if ( isDescendantHostnameOfIter(hostname, network) === false ) { + filteringModes.network.add(hostname); + pruneDescendantHostnamesFromSet(hostname, network); + } + break; + case 2: + if ( isDescendantHostnameOfIter(hostname, extendedSpecific) === false ) { + filteringModes.extendedSpecific.add(hostname); + pruneDescendantHostnamesFromSet(hostname, extendedSpecific); + } + break; + case 3: + if ( isDescendantHostnameOfIter(hostname, extendedGeneric) === false ) { + filteringModes.extendedGeneric.add(hostname); + pruneDescendantHostnamesFromSet(hostname, extendedGeneric); + } + break; + } + } + await setFilteringModeDetails(filteringModes); + return getFilteringMode(hostname); +} + +/******************************************************************************/ + +async function getDefaultFilteringMode() { + const filteringModes = await getFilteringModeDetails(); + if ( filteringModes.none.has('all-urls') ) { return 0; } + if ( filteringModes.network.has('all-urls') ) { return 1; } + if ( filteringModes.extendedSpecific.has('all-urls') ) { return 2; } + if ( filteringModes.extendedGeneric.has('all-urls') ) { return 3; } + return 1; +} + +/******************************************************************************/ + +async function setDefaultFilteringMode(afterLevel) { + const [ beforeLevel, filteringModes ] = await Promise.all([ + getDefaultFilteringMode(), + getFilteringModeDetails(), + ]); + if ( afterLevel === beforeLevel ) { return afterLevel; } + switch ( afterLevel ) { + case 0: + filteringModes.none.clear(); + filteringModes.none.add('all-urls'); + break; + case 1: + filteringModes.network.clear(); + filteringModes.network.add('all-urls'); + break; + case 2: + filteringModes.extendedSpecific.clear(); + filteringModes.extendedSpecific.add('all-urls'); + break; + case 3: + filteringModes.extendedGeneric.clear(); + filteringModes.extendedGeneric.add('all-urls'); + break; + } + switch ( beforeLevel ) { + case 0: + filteringModes.none.delete('all-urls'); + break; + case 1: + filteringModes.network.delete('all-urls'); + break; + case 2: + filteringModes.extendedSpecific.delete('all-urls'); + break; + case 3: + filteringModes.extendedGeneric.delete('all-urls'); + break; + } + await setFilteringModeDetails(filteringModes); + return getDefaultFilteringMode(); +} + +/******************************************************************************/ + +async function syncWithDemotedOrigins(demotedOrigins) { + const demotedHostnames = new Set(hostnamesFromMatches(demotedOrigins)); + if ( demotedHostnames.has('all-urls') ) { + await setDefaultFilteringMode(1); + } + const filteringModes = await getFilteringModeDetails(); + const { extendedSpecific, extendedGeneric } = filteringModes; + for ( const hn of extendedSpecific ) { + if ( demotedHostnames.has(hn) === false ) { continue; } + extendedSpecific.delete(hn); + } + for ( const hn of extendedGeneric ) { + if ( demotedHostnames.has(hn) === false ) { continue; } + extendedGeneric.delete(hn); + } + return setFilteringModeDetails(filteringModes); +} + +/******************************************************************************/ + +async function getAllTrustedSiteDirectives() { + const dynamicRuleMap = await getDynamicRules(); + const rule = dynamicRuleMap.get(TRUSTED_DIRECTIVE_BASE_RULE_ID); + if ( rule === undefined ) { return []; } + return rule.condition.requestDomains; +} + +/******************************************************************************/ + +export { + getFilteringMode, + setFilteringMode, + getDefaultFilteringMode, + setDefaultFilteringMode, + getFilteringModeDetails, + getAllTrustedSiteDirectives, + syncWithDemotedOrigins, +}; diff --git a/platform/mv3/extension/js/popup.js b/platform/mv3/extension/js/popup.js index 671fd30ea..ffa096006 100644 --- a/platform/mv3/extension/js/popup.js +++ b/platform/mv3/extension/js/popup.js @@ -32,69 +32,172 @@ import { simpleStorage } from './storage.js'; /******************************************************************************/ -let currentTab = {}; +const popupPanelData = {}; +const currentTab = {}; let tabHostname = ''; - /******************************************************************************/ -let originalStateHash = ''; - -function getCurrentStateHash() { - const parts = [ - dom.cl.has(dom.body, 'off'), - dom.cl.has(dom.body, 'hasGreatPowers'), - ]; - return parts.join('\t'); -} - -function onStateHashChanged() { - dom.cl.toggle( - dom.body, - 'needReload', - getCurrentStateHash() !== originalStateHash - ); +function normalizedHostname(hn) { + return hn.replace(/^www\./, ''); } /******************************************************************************/ -async function toggleTrustedSiteDirective() { - let url; - try { - url = new URL(currentTab.url); - } catch(ex) { - return; +const BLOCKING_MODE_MAX = 3; + +function setFilteringMode(level, commit = false) { + const modeSlider = qs$('.filteringModeSlider'); + modeSlider.dataset.level = level; + if ( qs$('.filteringModeSlider.moving') === null ) { + dom.text( + qs$('#filteringModeText > span:nth-of-type(1)'), + i18n$(`filteringMode${level}Name`) + ); } - if ( url instanceof URL === false ) { return; } + if ( commit !== true ) { return; } + commitFilteringMode(); +} - const targetTrustedState = dom.cl.has(dom.body, 'off'); - - const newTrustedState = await sendMessage({ - what: 'toggleTrustedSiteDirective', - origin: url.origin, - state: targetTrustedState, - tabId: currentTab.id, - }).catch(( ) => - targetTrustedState === false +async function commitFilteringMode() { + if ( tabHostname === '' ) { return; } + const targetHostname = normalizedHostname(tabHostname); + const modeSlider = qs$('.filteringModeSlider'); + const afterLevel = parseInt(modeSlider.dataset.level, 10); + const beforeLevel = parseInt(modeSlider.dataset.levelBefore, 10); + if ( afterLevel > 1 ) { + let granted = false; + try { + granted = await browser.permissions.request({ + origins: [ `*://*.${targetHostname}/*` ], + }); + } catch(ex) { + } + if ( granted !== true ) { + setFilteringMode(beforeLevel); + return; + } + } + dom.text( + qs$('#filteringModeText > span:nth-of-type(1)'), + i18n$(`filteringMode${afterLevel}Name`) ); - - dom.cl.toggle(dom.body, 'off', newTrustedState === true); - onStateHashChanged(); -} - -dom.on(qs$('#switch'), 'click', toggleTrustedSiteDirective); - -/******************************************************************************/ - -function reloadTab(ev) { - browser.tabs.reload(currentTab.id, { - bypassCache: ev.ctrlKey || ev.metaKey || ev.shiftKey, + const actualLevel = await sendMessage({ + what: 'setFilteringMode', + hostname: targetHostname, + level: afterLevel, }); - dom.cl.remove(dom.body, 'needReload'); - originalStateHash = getCurrentStateHash(); + if ( actualLevel !== afterLevel ) { + setFilteringMode(actualLevel); + } + if ( actualLevel !== beforeLevel && popupPanelData.autoReload ) { + browser.tabs.reload(currentTab.id); + } } -dom.on(qs$('#refresh'), 'click', reloadTab); +{ + let mx0 = 0; + let mx1 = 0; + let l0 = 0; + let lMax = 0; + let timer; + + const move = ( ) => { + timer = undefined; + const l1 = Math.min(Math.max(l0 + mx1 - mx0, 0), lMax); + let level = Math.floor(l1 * BLOCKING_MODE_MAX / lMax); + if ( qs$('body[dir="rtl"]') !== null ) { + level = 3 - level; + } + const modeSlider = qs$('.filteringModeSlider'); + if ( `${level}` === modeSlider.dataset.level ) { return; } + dom.text( + qs$('#filteringModeText > span:nth-of-type(2)'), + i18n$(`filteringMode${level}Name`) + ); + setFilteringMode(level); + }; + + const moveAsync = ev => { + if ( timer !== undefined ) { return; } + mx1 = ev.pageX; + timer = self.requestAnimationFrame(move); + }; + + const stop = ev => { + if ( ev.button !== 0 ) { return; } + const modeSlider = qs$('.filteringModeSlider'); + if ( dom.cl.has(modeSlider, 'moving') === false ) { return; } + dom.cl.remove(modeSlider, 'moving'); + self.removeEventListener('mousemove', moveAsync, { capture: true }); + self.removeEventListener('mouseup', stop, { capture: true }); + dom.text(qs$('#filteringModeText > span:nth-of-type(2)'), ''); + commitFilteringMode(); + ev.stopPropagation(); + ev.preventDefault(); + if ( timer !== undefined ) { + self.cancelAnimationFrame(timer); + timer = undefined; + } + }; + + const startSliding = ev => { + if ( ev.button !== 0 ) { return; } + const modeButton = qs$('.filteringModeButton'); + if ( ev.currentTarget !== modeButton ) { return; } + const modeSlider = qs$('.filteringModeSlider'); + if ( dom.cl.has(modeSlider, 'moving') ) { return; } + modeSlider.dataset.levelBefore = modeSlider.dataset.level; + mx0 = ev.pageX; + const buttonRect = modeButton.getBoundingClientRect(); + l0 = buttonRect.left + buttonRect.width / 2; + const sliderRect = modeSlider.getBoundingClientRect(); + lMax = sliderRect.width - buttonRect.width ; + dom.cl.add(modeSlider, 'moving'); + self.addEventListener('mousemove', moveAsync, { capture: true }); + self.addEventListener('mouseup', stop, { capture: true }); + ev.stopPropagation(); + ev.preventDefault(); + }; + + dom.on(qs$('.filteringModeButton'), 'mousedown', startSliding); +} + +dom.on( + qs$('.filteringModeSlider'), + 'click', + '.filteringModeSlider span[data-level]', + ev => { + const modeSlider = qs$('.filteringModeSlider'); + modeSlider.dataset.levelBefore = modeSlider.dataset.level; + const span = ev.target; + const level = parseInt(span.dataset.level, 10); + setFilteringMode(level, true); + } +); + +dom.on( + qs$('.filteringModeSlider'), + 'mouseenter', + '.filteringModeSlider span[data-level]', + ev => { + const span = ev.target; + const level = parseInt(span.dataset.level, 10); + dom.text( + qs$('#filteringModeText > span:nth-of-type(2)'), + i18n$(`filteringMode${level}Name`) + ); + } +); + +dom.on( + qs$('.filteringModeSlider'), + 'mouseleave', + '.filteringModeSlider span[data-level]', + ( ) => { + dom.text(qs$('#filteringModeText > span:nth-of-type(2)'), ''); + } +); /******************************************************************************/ @@ -156,42 +259,10 @@ dom.on(qs$('#lessButton'), 'click', ( ) => { /******************************************************************************/ -async function grantGreatPowers() { - if ( tabHostname === '' ) { return; } - const targetHostname = tabHostname.replace(/^www\./, ''); - const granted = await browser.permissions.request({ - origins: [ `*://*.${targetHostname}/*` ], - }); - if ( granted !== true ) { return; } - dom.cl.add(dom.body, 'hasGreatPowers'); - onStateHashChanged(); -} - -async function revokeGreatPowers() { - if ( tabHostname === '' ) { return; } - const targetHostname = tabHostname.replace(/^www\./, ''); - const removed = await browser.permissions.remove({ - origins: [ `*://*.${targetHostname}/*` ], - }); - if ( removed !== true ) { return; } - dom.cl.remove(dom.body, 'hasGreatPowers'); - onStateHashChanged(); -} - -dom.on(qs$('#toggleGreatPowers'), 'click', ( ) => { - if ( dom.cl.has(dom.body, 'hasGreatPowers' ) ) { - revokeGreatPowers(); - } else { - grantGreatPowers(); - } -}); - -/******************************************************************************/ - async function init() { const [ tab ] = await browser.tabs.query({ active: true }); if ( tab instanceof Object === false ) { return true; } - currentTab = tab; + Object.assign(currentTab, tab); let url; try { @@ -200,37 +271,20 @@ async function init() { } catch(ex) { } - let popupPanelData = {}; if ( url !== undefined ) { - popupPanelData = await sendMessage({ + const response = await sendMessage({ what: 'popupPanelData', origin: url.origin, + hostname: normalizedHostname(tabHostname), }); + if ( response instanceof Object ) { + Object.assign(popupPanelData, response); + } } - dom.cl.toggle( - dom.body, - 'off', - popupPanelData.isTrusted === true - ); - - dom.cl.toggle( - dom.body, - 'hasOmnipotence', - popupPanelData.hasOmnipotence === true - ); - - dom.cl.toggle( - dom.body, - 'hasGreatPowers', - popupPanelData.hasGreatPowers === true - ); + setFilteringMode(popupPanelData.level); dom.text(qs$('#hostname'), tabHostname); - dom.text( - qs$('#toggleGreatPowers .badge'), - popupPanelData.injectableCount || '' - ); const parent = qs$('#rulesetStats'); for ( const details of popupPanelData.rulesetDetails || [] ) { @@ -253,8 +307,6 @@ async function init() { dom.cl.remove(dom.body, 'loading'); - originalStateHash = getCurrentStateHash(); - return true; } diff --git a/platform/mv3/extension/js/ruleset-manager.js b/platform/mv3/extension/js/ruleset-manager.js index 1baeb58fd..0ce93d751 100644 --- a/platform/mv3/extension/js/ruleset-manager.js +++ b/platform/mv3/extension/js/ruleset-manager.js @@ -36,6 +36,7 @@ const REGEXES_REALM_END = REGEXES_REALM_START + RULE_REALM_SIZE; const REMOVEPARAMS_REALM_START = 2000000; const REMOVEPARAMS_REALM_END = REMOVEPARAMS_REALM_START + RULE_REALM_SIZE; const TRUSTED_DIRECTIVE_BASE_RULE_ID = 8000000; +const BLOCKING_MODES_RULE_ID = TRUSTED_DIRECTIVE_BASE_RULE_ID + 1; const CURRENT_CONFIG_BASE_RULE_ID = 9000000; /******************************************************************************/ @@ -67,8 +68,8 @@ function getDynamicRules() { const map = new Map( rules.map(rule => [ rule.id, rule ]) ); - console.log(`Dynamic rule count: ${map.size}`); - console.log(`Available dynamic rule count: ${dnr.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES - map.size}`); + console.info(`Dynamic rule count: ${map.size}`); + console.info(`Available dynamic rule count: ${dnr.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES - map.size}`); return map; }); return dynamicRuleMapPromise; @@ -362,8 +363,9 @@ async function getEnabledRulesetsDetails() { /******************************************************************************/ export { - TRUSTED_DIRECTIVE_BASE_RULE_ID, + BLOCKING_MODES_RULE_ID, CURRENT_CONFIG_BASE_RULE_ID, + TRUSTED_DIRECTIVE_BASE_RULE_ID, getRulesetDetails, getDynamicRules, enableRulesets, diff --git a/platform/mv3/extension/js/scripting-manager.js b/platform/mv3/extension/js/scripting-manager.js index 67ecdd232..250c336e3 100644 --- a/platform/mv3/extension/js/scripting-manager.js +++ b/platform/mv3/extension/js/scripting-manager.js @@ -27,7 +27,8 @@ import { browser, dnr } from './ext.js'; import { fetchJSON } from './fetch.js'; -import { getAllTrustedSiteDirectives } from './trusted-sites.js'; +import { getFilteringModeDetails } from './mode-manager.js'; +import { getEnabledRulesetsDetails } from './ruleset-manager.js'; import * as ut from './utils.js'; @@ -51,44 +52,8 @@ function getScriptingDetails() { /******************************************************************************/ -const toRegisterable = (fname, hostnames, trustedSites) => { - const directive = { - id: fname, - allFrames: true, - matchOriginAsFallback: true, - }; - if ( hostnames ) { - directive.matches = ut.matchesFromHostnames(hostnames); - } else { - directive.matches = [ '' ]; - } - if ( - directive.matches.length === 1 && - directive.matches[0] === '' - ) { - directive.excludeMatches = ut.matchesFromHostnames(trustedSites); - } - directive.js = [ `/rulesets/js/${fname.slice(0,2)}/${fname.slice(2)}.js` ]; - if ( (ut.fidFromFileName(fname) & RUN_AT_END_BIT) !== 0 ) { - directive.runAt = 'document_end'; - } else { - directive.runAt = 'document_start'; - } - if ( (ut.fidFromFileName(fname) & MAIN_WORLD_BIT) !== 0 ) { - directive.world = 'MAIN'; - } - return directive; -}; - -const RUN_AT_END_BIT = 0b10; -const MAIN_WORLD_BIT = 0b01; - -/******************************************************************************/ - // Important: We need to sort the arrays for fast comparison -const arrayEq = (a, b) => { - if ( a === undefined ) { return b === undefined; } - if ( b === undefined ) { return false; } +const arrayEq = (a = [], b = []) => { const alen = a.length; if ( alen !== b.length ) { return false; } a.sort(); b.sort(); @@ -98,30 +63,129 @@ const arrayEq = (a, b) => { return true; }; -const shouldUpdate = (registered, afterHostnames, afterExcludeHostnames) => { - if ( afterHostnames.length === 1 && afterHostnames[0] === '*' ) { - const beforeExcludeHostnames = registered.excludeMatches && - ut.hostnamesFromMatches(registered.excludeMatches) || - []; - if ( arrayEq(beforeExcludeHostnames, afterExcludeHostnames) === false ) { - return true; - } +/******************************************************************************/ + +const toRegisterableScript = (context, fname, hostnames) => { + if ( context.before.has(fname) ) { + return toUpdatableScript(context, fname, hostnames); } - const beforeHostnames = registered.matches && - ut.hostnamesFromMatches(registered.matches) || - []; - return arrayEq(beforeHostnames, afterHostnames) === false; + const matches = hostnames + ? ut.matchesFromHostnames(hostnames) + : [ '' ]; + const excludeMatches = matches.length === 1 && matches[0] === '' + ? ut.matchesFromHostnames(context.filteringModeDetails.none) + : []; + const runAt = (ut.fidFromFileName(fname) & RUN_AT_END_BIT) !== 0 + ? 'document_end' + : 'document_start'; + const directive = { + id: fname, + matches, + excludeMatches, + js: [ `/rulesets/js/${fname.slice(0,2)}/${fname.slice(2)}.js` ], + runAt, + }; + if ( (ut.fidFromFileName(fname) & MAIN_WORLD_BIT) !== 0 ) { + directive.world = 'MAIN'; + } + context.toAdd.push(directive); }; -const isTrustedHostname = (trustedSites, hn) => { - if ( trustedSites.size === 0 ) { return false; } - while ( hn ) { - if ( trustedSites.has(hn) ) { return true; } - hn = ut.toBroaderHostname(hn); +const toUpdatableScript = (context, fname, hostnames) => { + const registered = context.before.get(fname); + context.before.delete(fname); // Important! + const directive = { id: fname }; + const matches = hostnames + ? ut.matchesFromHostnames(hostnames) + : [ '' ]; + if ( arrayEq(registered.matches, matches) === false ) { + directive.matches = matches; + } + const excludeMatches = matches.length === 1 && matches[0] === '' + ? ut.matchesFromHostnames(context.filteringModeDetails.none) + : []; + if ( arrayEq(registered.excludeMatches, excludeMatches) === false ) { + directive.excludeMatches = excludeMatches; + } + if ( directive.matches || directive.excludeMatches ) { + context.toUpdate.push(directive); } - return false; }; +const RUN_AT_END_BIT = 0b10; +const MAIN_WORLD_BIT = 0b01; + +/******************************************************************************/ + +async function registerGeneric(context, args) { + const { before } = context; + const registered = before.get('css-generic'); + before.delete('css-generic'); // Important! + + const { + filteringModeDetails, + rulesetsDetails, + } = args; + + const js = []; + for ( const details of rulesetsDetails ) { + if ( details.css.generic.count === 0 ) { continue; } + js.push(`/rulesets/js/${details.id}.generic.js`); + } + + if ( js.length === 0 ) { + if ( registered !== undefined ) { + context.toRemove.push('css-generic'); + } + return; + } + + const matches = []; + const excludeMatches = []; + if ( filteringModeDetails.extendedGeneric.has('all-urls') ) { + excludeMatches.push(...ut.matchesFromHostnames(filteringModeDetails.none)); + excludeMatches.push(...ut.matchesFromHostnames(filteringModeDetails.network)); + excludeMatches.push(...ut.matchesFromHostnames(filteringModeDetails.extendedSpecific)); + matches.push(''); + } else { + matches.push(...ut.matchesFromHostnames(filteringModeDetails.extendedGeneric)); + } + + if ( matches.length === 0 ) { + if ( registered !== undefined ) { + context.toRemove.push('css-generic'); + } + return; + } + + // register + if ( registered === undefined ) { + context.toAdd.push({ + id: 'css-generic', + js, + matches, + excludeMatches, + runAt: 'document_idle', + }); + return; + } + + // update + const directive = { id: 'css-generic' }; + if ( arrayEq(registered.js, js) === false ) { + directive.js = js; + } + if ( arrayEq(registered.matches, matches) === false ) { + directive.matches = matches; + } + if ( arrayEq(registered.excludeMatches, excludeMatches) === false ) { + directive.excludeMatches = excludeMatches; + } + if ( directive.js || directive.matches || directive.excludeMatches ) { + context.toUpdate.push(directive); + } +} + /******************************************************************************/ async function getInjectableCount(origin) { @@ -158,16 +222,27 @@ async function getInjectableCount(origin) { /******************************************************************************/ -function registerSomeInjectables(args) { +function registerSpecific(args) { const { - hostnamesSet, - trustedSites, - rulesetIds, + filteringModeDetails, + rulesetsDetails, scriptingDetails, } = args; + // Combined both specific and generic sets + if ( + filteringModeDetails.extendedSpecific.has('all-urls') || + filteringModeDetails.extendedGeneric.has('all-urls') + ) { + return registerAllSpecific(args); + } + + const targetHostnames = [ + ...filteringModeDetails.extendedSpecific, + ...filteringModeDetails.extendedGeneric, + ]; + const toRegisterMap = new Map(); - const trustedSitesSet = new Set(trustedSites); const checkMatches = (hostnamesToFidsMap, hn) => { let fids = hostnamesToFidsMap.get(hn); @@ -189,11 +264,10 @@ function registerSomeInjectables(args) { } }; - for ( const rulesetId of rulesetIds ) { - const hostnamesToFidsMap = scriptingDetails.get(rulesetId); + for ( const rulesetDetails of rulesetsDetails ) { + const hostnamesToFidsMap = scriptingDetails.get(rulesetDetails.id); if ( hostnamesToFidsMap === undefined ) { continue; } - for ( let hn of hostnamesSet ) { - if ( isTrustedHostname(trustedSitesSet, hn) ) { continue; } + for ( let hn of targetHostnames ) { while ( hn ) { checkMatches(hostnamesToFidsMap, hn); hn = ut.toBroaderHostname(hn); @@ -204,21 +278,25 @@ function registerSomeInjectables(args) { return toRegisterMap; } -function registerAllInjectables(args) { +function registerAllSpecific(args) { const { - trustedSites, - rulesetIds, + filteringModeDetails, + rulesetsDetails, scriptingDetails, } = args; const toRegisterMap = new Map(); - const trustedSitesSet = new Set(trustedSites); + const excludeSet = new Set([ + ...filteringModeDetails.network, + ...filteringModeDetails.none, + ]); - for ( const rulesetId of rulesetIds ) { - const hostnamesToFidsMap = scriptingDetails.get(rulesetId); + for ( const rulesetDetails of rulesetsDetails ) { + const hostnamesToFidsMap = scriptingDetails.get(rulesetDetails.id); if ( hostnamesToFidsMap === undefined ) { continue; } for ( let [ hn, fids ] of hostnamesToFidsMap ) { - if ( isTrustedHostname(trustedSitesSet, hn) ) { continue; } + if ( excludeSet.has(hn) ) { continue; } + if ( ut.isDescendantHostnameOfIter(hn, excludeSet) ) { continue; } if ( typeof fids === 'number' ) { fids = [ fids ]; } for ( const fid of fids ) { const fname = ut.fnameFromFileId(fid); @@ -248,80 +326,65 @@ async function registerInjectables(origins) { if ( browser.scripting === undefined ) { return false; } const [ - hostnamesSet, - trustedSites, - rulesetIds, + filteringModeDetails, + rulesetsDetails, scriptingDetails, registered, ] = await Promise.all([ - browser.permissions.getAll(), - getAllTrustedSiteDirectives(), - dnr.getEnabledRulesets(), + getFilteringModeDetails(), + getEnabledRulesetsDetails(), getScriptingDetails(), browser.scripting.getRegisteredContentScripts(), - ]).then(results => { - results[0] = new Set(ut.hostnamesFromMatches(results[0].origins)); - return results; - }); - - const toRegisterMap = hostnamesSet.has('*') - ? registerAllInjectables({ - trustedSites, - rulesetIds, - scriptingDetails, - }) - : registerSomeInjectables({ - hostnamesSet, - trustedSites, - rulesetIds, - scriptingDetails, - }); + ]); const before = new Map(registered.map(entry => [ entry.id, entry ])); + const toAdd = [], toUpdate = [], toRemove = []; + const promises = []; + const context = { + filteringModeDetails, + before, + toAdd, + toUpdate, + toRemove, + }; + + await registerGeneric(context, { filteringModeDetails, rulesetsDetails, }); + + const toRegisterMap = registerSpecific({ + filteringModeDetails, + rulesetsDetails, + scriptingDetails, + }); - const toAdd = []; - const toUpdate = []; for ( const [ fname, hostnames ] of toRegisterMap ) { - if ( before.has(fname) === false ) { - toAdd.push(toRegisterable(fname, hostnames, trustedSites)); - continue; - } - if ( shouldUpdate(before.get(fname), hostnames, trustedSites) ) { - toUpdate.push(toRegisterable(fname, hostnames, trustedSites)); - } + toRegisterableScript(context, fname, hostnames); } + toRemove.push(...Array.from(before.keys())); - const toRemove = []; - for ( const fname of before.keys() ) { - if ( toRegisterMap.has(fname) ) { continue; } - toRemove.push(fname); - } - - const todo = []; if ( toRemove.length !== 0 ) { console.info(`Unregistered ${toRemove} content (css/js)`); - todo.push( + promises.push( browser.scripting.unregisterContentScripts({ ids: toRemove }) .catch(reason => { console.info(reason); }) ); } if ( toAdd.length !== 0 ) { console.info(`Registered ${toAdd.map(v => v.id)} content (css/js)`); - todo.push( + promises.push( browser.scripting.registerContentScripts(toAdd) .catch(reason => { console.info(reason); }) ); } if ( toUpdate.length !== 0 ) { console.info(`Updated ${toUpdate.map(v => v.id)} content (css/js)`); - todo.push( + promises.push( browser.scripting.updateContentScripts(toUpdate) .catch(reason => { console.info(reason); }) ); } - if ( todo.length === 0 ) { return; } + if ( promises.length === 0 ) { return; } - return Promise.all(todo); + return Promise.all(promises); } /******************************************************************************/ diff --git a/platform/mv3/extension/js/3p-filters.js b/platform/mv3/extension/js/settings.js similarity index 83% rename from platform/mv3/extension/js/3p-filters.js rename to platform/mv3/extension/js/settings.js index c1b5f3d40..b36395124 100644 --- a/platform/mv3/extension/js/3p-filters.js +++ b/platform/mv3/extension/js/settings.js @@ -32,7 +32,6 @@ import { simpleStorage } from './storage.js'; const rulesetMap = new Map(); let cachedRulesetData = {}; -let filteringSettingsHash = ''; let hideUnusedSet = new Set([ 'regions' ]); /******************************************************************************/ @@ -44,7 +43,7 @@ function renderNumber(value) { /******************************************************************************/ function rulesetStats(rulesetId) { - const canRemoveParams = cachedRulesetData.hasOmnipotence; + const canRemoveParams = cachedRulesetData.defaultFilteringMode > 1; const rulesetDetails = rulesetMap.get(rulesetId); if ( rulesetDetails === undefined ) { return; } const { rules, filters } = rulesetDetails; @@ -202,27 +201,20 @@ function renderFilterLists(soft = false) { dom.remove(qsa$('#lists .listEntries .listEntry.discard')); - // Compute a hash of the settings so that we can keep track of changes - // affecting the loading of filter lists. - if ( !soft ) { - filteringSettingsHash = hashFromCurrentFromSettings(); - } - renderWidgets(); } /******************************************************************************/ const renderWidgets = function() { - dom.cl.toggle(dom.body, 'firstRun', cachedRulesetData.firstRun === true); + if ( cachedRulesetData.firstRun ) { + dom.cl.add(dom.body, 'firstRun'); + } - qs$('#omnipotenceWidget input').checked = cachedRulesetData.hasOmnipotence; + const defaultLevel = cachedRulesetData.defaultFilteringMode; + qs$(`.filteringModeCard input[type="radio"][value="${defaultLevel}"]`).checked = true; - dom.cl.toggle( - qs$('#buttonApply'), - 'disabled', - filteringSettingsHash === hashFromCurrentFromSettings() - ); + qs$('#autoReload input[type="checkbox"').checked = cachedRulesetData.autoReload; // Compute total counts let filterCount = 0; @@ -241,71 +233,54 @@ const renderWidgets = function() { /******************************************************************************/ -async function onOmnipotenceChanged(ev) { +async function onFilteringModeChange(ev) { const input = ev.target; - const newState = input.checked; + const newLevel = parseInt(input.value, 10); + let granted = false; - const oldState = await browser.permissions.contains({ - origins: [ '' ] - }); - if ( newState === oldState ) { return; } - - let actualState; - if ( newState ) { - actualState = await browser.permissions.request({ + switch ( newLevel ) { + case 1: { // Revoke broad permissions + granted = await browser.permissions.remove({ origins: [ '' ] }); - } else { - actualState = await browser.permissions.remove({ - origins: [ '' ] - }) !== true; + break; + } + case 2: + case 3: { // Request broad permissions + granted = await browser.permissions.request({ + origins: [ '' ] + }); + break; + } + default: + break; + } + if ( granted ) { + const actualLevel = await sendMessage({ + what: 'setDefaultFilteringMode', + level: newLevel, + }); + cachedRulesetData.defaultFilteringMode = actualLevel; } - - cachedRulesetData.hasOmnipotence = actualState; - qs$('#omnipotenceWidget input').checked = actualState; renderFilterLists(true); renderWidgets(); } dom.on( - qs$('#omnipotenceWidget input'), + qs$('#defaultFilteringMode'), 'change', - ev => { onOmnipotenceChanged(ev); } + '.filteringModeCard input[type="radio"]', + ev => { onFilteringModeChange(ev); } ); /******************************************************************************/ -function hashFromCurrentFromSettings() { - const hash = []; - const listHash = []; - for ( const liEntry of qsa$('#lists .listEntry[data-listkey]') ) { - if ( qs$('input[type="checkbox"]:checked', liEntry) ) { - listHash.push(dom.attr(liEntry, 'data-listkey')); - } - } - hash.push(listHash.sort().join()); - return hash.join(); -} - -self.hasUnsavedData = function() { - return hashFromCurrentFromSettings() !== filteringSettingsHash; -}; - -/******************************************************************************/ - -function onListsetChanged(ev) { - const input = ev.target; - const li = input.closest('.listEntry'); - dom.cl.toggle(li, 'checked', input.checked); - renderWidgets(); -} - -dom.on( - qs$('#lists'), - 'change', - '.listEntry input', - onListsetChanged -); +dom.on(qs$('#autoReload input[type="checkbox"'), 'change', ev => { + sendMessage({ + what: 'setAutoReload', + state: ev.target.checked, + }); +}); /******************************************************************************/ @@ -321,20 +296,12 @@ async function applyEnabledRulesets() { enabledRulesets, }); - filteringSettingsHash = hashFromCurrentFromSettings(); -} - -async function buttonApplyHandler() { - dom.cl.remove(qs$('#buttonApply'), 'enabled'); - await applyEnabledRulesets(); renderWidgets(); } -dom.on( - qs$('#buttonApply'), - 'click', - ( ) => { buttonApplyHandler(); } -); +dom.on(qs$('#lists'), 'change', '.listEntry input[type="checkbox"]', ( ) => { + applyEnabledRulesets(); +}); /******************************************************************************/ @@ -406,7 +373,7 @@ simpleStorage.getItem('hideUnusedFilterLists').then(value => { /******************************************************************************/ sendMessage({ - what: 'getRulesetData', + what: 'getOptionsPageData', }).then(data => { if ( !data ) { return; } cachedRulesetData = data; diff --git a/platform/mv3/extension/js/trusted-sites.js b/platform/mv3/extension/js/trusted-sites.js deleted file mode 100644 index bfed7f780..000000000 --- a/platform/mv3/extension/js/trusted-sites.js +++ /dev/null @@ -1,170 +0,0 @@ -/******************************************************************************* - - uBlock Origin - a browser extension to block requests. - 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 { dnr } from './ext.js'; - -import { - parsedURLromOrigin, - toBroaderHostname, -} from './utils.js'; - -import { - TRUSTED_DIRECTIVE_BASE_RULE_ID, - getDynamicRules -} from './ruleset-manager.js'; - -/******************************************************************************/ - -async function getAllTrustedSiteDirectives() { - const dynamicRuleMap = await getDynamicRules(); - const rule = dynamicRuleMap.get(TRUSTED_DIRECTIVE_BASE_RULE_ID); - if ( rule === undefined ) { return []; } - return rule.condition.requestDomains; -} - -/******************************************************************************/ - -async function matchesTrustedSiteDirective(details) { - const hostname = - details.hostname || - parsedURLromOrigin(details.origin)?.hostname || - undefined; - if ( hostname === undefined ) { return false; } - - const dynamicRuleMap = await getDynamicRules(); - const rule = dynamicRuleMap.get(TRUSTED_DIRECTIVE_BASE_RULE_ID); - if ( rule === undefined ) { return false; } - - const domainSet = new Set(rule.condition.requestDomains); - let hn = hostname; - while ( hn ) { - if ( domainSet.has(hn) ) { return true; } - hn = toBroaderHostname(hn); - } - - return false; -} - -/******************************************************************************/ - -async function addTrustedSiteDirective(details) { - const url = parsedURLromOrigin(details.origin); - if ( url === undefined ) { return false; } - - const dynamicRuleMap = await getDynamicRules(); - let rule = dynamicRuleMap.get(TRUSTED_DIRECTIVE_BASE_RULE_ID); - if ( rule !== undefined ) { - rule.condition.initiatorDomains = undefined; - if ( Array.isArray(rule.condition.requestDomains) === false ) { - rule.condition.requestDomains = []; - } - } - - if ( rule === undefined ) { - rule = { - id: TRUSTED_DIRECTIVE_BASE_RULE_ID, - action: { - type: 'allowAllRequests', - }, - condition: { - requestDomains: [ url.hostname ], - resourceTypes: [ 'main_frame' ], - }, - priority: TRUSTED_DIRECTIVE_BASE_RULE_ID, - }; - dynamicRuleMap.set(TRUSTED_DIRECTIVE_BASE_RULE_ID, rule); - } else if ( rule.condition.requestDomains.includes(url.hostname) === false ) { - rule.condition.requestDomains.push(url.hostname); - } - - await dnr.updateDynamicRules({ - addRules: [ rule ], - removeRuleIds: [ TRUSTED_DIRECTIVE_BASE_RULE_ID ], - }); - - return true; -} - -/******************************************************************************/ - -async function removeTrustedSiteDirective(details) { - const url = parsedURLromOrigin(details.origin); - if ( url === undefined ) { return false; } - - const dynamicRuleMap = await getDynamicRules(); - let rule = dynamicRuleMap.get(TRUSTED_DIRECTIVE_BASE_RULE_ID); - if ( rule === undefined ) { return false; } - rule.condition.initiatorDomains = undefined; - if ( Array.isArray(rule.condition.requestDomains) === false ) { - rule.condition.requestDomains = []; - } - - const domainSet = new Set(rule.condition.requestDomains); - const beforeCount = domainSet.size; - let hostname = url.hostname; - for (;;) { - domainSet.delete(hostname); - const pos = hostname.indexOf('.'); - if ( pos === -1 ) { break; } - hostname = hostname.slice(pos+1); - } - - if ( domainSet.size === beforeCount ) { return false; } - - if ( domainSet.size === 0 ) { - dynamicRuleMap.delete(TRUSTED_DIRECTIVE_BASE_RULE_ID); - await dnr.updateDynamicRules({ - removeRuleIds: [ TRUSTED_DIRECTIVE_BASE_RULE_ID ] - }); - return false; - } - - rule.condition.requestDomains = Array.from(domainSet); - - await dnr.updateDynamicRules({ - addRules: [ rule ], - removeRuleIds: [ TRUSTED_DIRECTIVE_BASE_RULE_ID ], - }); - - return false; -} - -/******************************************************************************/ - -async function toggleTrustedSiteDirective(details) { - return details.state - ? removeTrustedSiteDirective(details) - : addTrustedSiteDirective(details); -} - -/******************************************************************************/ - -export { - getAllTrustedSiteDirectives, - matchesTrustedSiteDirective, - toggleTrustedSiteDirective, -}; diff --git a/platform/mv3/extension/js/utils.js b/platform/mv3/extension/js/utils.js index 3ec940d42..43aa96e19 100644 --- a/platform/mv3/extension/js/utils.js +++ b/platform/mv3/extension/js/utils.js @@ -42,10 +42,28 @@ const toBroaderHostname = hn => { /******************************************************************************/ +// Is a descendant hostname of b? + +const isDescendantHostname = (a, b) => { + if ( b === 'all-urls' ) { return true; } + if ( a.endsWith(b) === false ) { return false; } + if ( a === b ) { return false; } + return a.charCodeAt(a.length - b.length - 1) === 0x2E /* '.' */; +}; + +const isDescendantHostnameOfIter = (a, iter) => { + for ( const b of iter ) { + if ( isDescendantHostname(a, b) ) { return true; } + } + return false; +}; + +/******************************************************************************/ + const matchesFromHostnames = hostnames => { const out = []; for ( const hn of hostnames ) { - if ( hn === '*' ) { + if ( hn === '*' || hn === 'all-urls' ) { out.length = 0; out.push(''); break; @@ -59,9 +77,8 @@ const hostnamesFromMatches = origins => { const out = []; for ( const origin of origins ) { if ( origin === '' ) { - out.length = 0; - out.push('*'); - break; + out.push('all-urls'); + continue; } const match = /^\*:\/\/(?:\*\.)?([^\/]+)\/\*/.exec(origin); if ( match === null ) { continue; } @@ -83,6 +100,8 @@ const fidFromFileName = fname => export { parsedURLromOrigin, toBroaderHostname, + isDescendantHostname, + isDescendantHostnameOfIter, matchesFromHostnames, hostnamesFromMatches, fnameFromFileId, diff --git a/platform/mv3/extension/popup.html b/platform/mv3/extension/popup.html index 38dd72888..6af293570 100644 --- a/platform/mv3/extension/popup.html +++ b/platform/mv3/extension/popup.html @@ -8,46 +8,25 @@ +
+
_
-
-
-
- lock - eraser -
-
- - - - - - - -
-
- refresh -
-
-
­
+
+
+ + + +
+
­
- - sun-o - sun - - + diff --git a/platform/mv3/extension/settings.html b/platform/mv3/extension/settings.html new file mode 100644 index 000000000..88b5e44fd --- /dev/null +++ b/platform/mv3/extension/settings.html @@ -0,0 +1,109 @@ + + + + + +uBlock Origin Lite — Filter lists + + + + + + + + + + +
+

+

+
+ +
+

+

+
+ + + +
+
+ +
+

+

+

+
+ +
+

Filter lists

+
+

+
+
+
+
+
+ +
+
+
+
+
+ +
+ + + + + + + + diff --git a/platform/mv3/make-rulesets.js b/platform/mv3/make-rulesets.js index 35801ef0b..011b0f821 100644 --- a/platform/mv3/make-rulesets.js +++ b/platform/mv3/make-rulesets.js @@ -382,15 +382,54 @@ function addScriptingAPIResources(id, hostnames, fid) { } } -const toCSSFileId = s => (uidint32(s) & ~0b11) | 0b00; -const toJSFileId = s => (uidint32(s) & ~0b11) | 0b01; -const toProceduralFileId = s => (uidint32(s) & ~0b11) | 0b10; +const toIsolatedStartFileId = s => (uidint32(s) & ~0b11) | 0b00; +const toMainStartFileId = s => (uidint32(s) & ~0b11) | 0b01; +const toIsolatedEndFileId = s => (uidint32(s) & ~0b11) | 0b10; const pathFromFileName = fname => `${scriptletDir}/${fname.slice(0,2)}/${fname.slice(2)}.js`; /******************************************************************************/ -const MAX_COSMETIC_FILTERS_PER_FILE = 128; +async function processGenericCosmeticFilters(assetDetails, bucketsMap, exclusions) { + const out = { + count: 0, + exclusionCount: 0, + }; + if ( bucketsMap === undefined ) { return out; } + if ( bucketsMap.size === 0 ) { return out; } + const bucketsList = Array.from(bucketsMap); + const count = bucketsList.reduce((a, v) => a += v[1].length, 0); + if ( count === 0 ) { return out; } + out.count = count; + + const selectorLists = bucketsList.map(v => [ v[0], v[1].join(',') ]); + const originalScriptletMap = await loadAllSourceScriptlets(); + + const patchedScriptlet = originalScriptletMap.get('css-generic') + .replace( + '$rulesetId$', + assetDetails.id + ).replace( + /\bself\.\$excludeHostnameSet\$/m, + `${JSON.stringify(exclusions, scriptletJsonReplacer)}` + ).replace( + /\bself\.\$genericSelectorLists\$/m, + `${JSON.stringify(selectorLists, scriptletJsonReplacer)}` + ); + + writeFile( + `${scriptletDir}/${assetDetails.id}.generic.js`, + patchedScriptlet + ); + + log(`CSS-generic: ${count} plain CSS selectors`); + + return out; +} + +/******************************************************************************/ + +const MAX_COSMETIC_FILTERS_PER_FILE = 256; // This merges selectors which are used by the same hostnames @@ -530,11 +569,11 @@ async function processCosmeticFilters(assetDetails, mapin) { /\bself\.\$hostnamesMap\$/m, `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` ); - const fid = toCSSFileId(patchedScriptlet); + const fid = toIsolatedStartFileId(patchedScriptlet); if ( globalPatchedScriptletsSet.has(fid) === false ) { globalPatchedScriptletsSet.add(fid); const fname = fnameFromFileId(fid); - writeFile(pathFromFileName(fname), patchedScriptlet, {}); + writeFile(pathFromFileName(fname), patchedScriptlet); generatedFiles.push(fname); } for ( const entry of slice ) { @@ -543,8 +582,8 @@ async function processCosmeticFilters(assetDetails, mapin) { } if ( generatedFiles.length !== 0 ) { - log(`CSS-related distinct filters: ${contentArray.length} distinct combined selectors`); - log(`CSS-related injectable files: ${generatedFiles.length}`); + log(`CSS-specific distinct filters: ${contentArray.length} distinct combined selectors`); + log(`CSS-specific injectable files: ${generatedFiles.length}`); log(`\t${generatedFiles.join(', ')}`); } @@ -596,11 +635,11 @@ async function processProceduralCosmeticFilters(assetDetails, mapin) { /\bself\.\$hostnamesMap\$/m, `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` ); - const fid = toProceduralFileId(patchedScriptlet); + const fid = toIsolatedEndFileId(patchedScriptlet); if ( globalPatchedScriptletsSet.has(fid) === false ) { globalPatchedScriptletsSet.add(fid); const fname = fnameFromFileId(fid); - writeFile(pathFromFileName(fname), patchedScriptlet, {}); + writeFile(pathFromFileName(fname), patchedScriptlet); generatedFiles.push(fname); } for ( const entry of slice ) { @@ -725,11 +764,11 @@ async function processScriptletFilters(assetDetails, mapin) { `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` ); // ends-with 1 = scriptlet resource - const fid = toJSFileId(patchedScriptlet); + const fid = toMainStartFileId(patchedScriptlet); if ( globalPatchedScriptletsSet.has(fid) === false ) { globalPatchedScriptletsSet.add(fid); const fname = fnameFromFileId(fid); - writeFile(pathFromFileName(fname), patchedScriptlet, {}); + writeFile(pathFromFileName(fname), patchedScriptlet); generatedFiles.push(fname); } for ( const details of argsDetails.values() ) { @@ -771,8 +810,8 @@ const rulesetFromURLS = async function(assetDetails) { const declarativeCosmetic = new Map(); const proceduralCosmetic = new Map(); const rejectedCosmetic = []; - if ( results.cosmetic ) { - for ( const [ selector, details ] of results.cosmetic ) { + if ( results.specificCosmetic ) { + for ( const [ selector, details ] of results.specificCosmetic ) { if ( details.rejected ) { rejectedCosmetic.push(selector); continue; @@ -786,7 +825,12 @@ const rulesetFromURLS = async function(assetDetails) { proceduralCosmetic.set(JSON.stringify(parsed), details); } } - const cosmeticStats = await processCosmeticFilters( + const genericCosmeticStats = await processGenericCosmeticFilters( + assetDetails, + results.genericCosmetic, + results.network.generichideExclusions.filter(hn => hn.endsWith('.*') === false) + ); + const specificCosmeticStats = await processCosmeticFilters( assetDetails, declarativeCosmetic ); @@ -824,7 +868,8 @@ const rulesetFromURLS = async function(assetDetails) { rejected: netStats.rejected, }, css: { - specific: cosmeticStats, + generic: genericCosmeticStats, + specific: specificCosmeticStats, procedural: proceduralStats, }, scriptlets: { @@ -896,20 +941,36 @@ async function main() { 'ara-0', 'EST-0', ]; + // Merge lists which have same target languages + const langToListsMap = new Map(); for ( const [ id, asset ] of Object.entries(assets) ) { if ( asset.content !== 'filters' ) { continue; } if ( asset.off !== true ) { continue; } if ( typeof asset.lang !== 'string' ) { continue; } if ( excludedLists.includes(id) ) { continue; } - const contentURL = Array.isArray(asset.contentURL) - ? asset.contentURL[0] - : asset.contentURL; + let ids = langToListsMap.get(asset.lang); + if ( ids === undefined ) { + langToListsMap.set(asset.lang, ids = []); + } + ids.push(id); + } + for ( const ids of langToListsMap.values() ) { + const urls = []; + for ( const id of ids ) { + const asset = assets[id]; + const contentURL = Array.isArray(asset.contentURL) + ? asset.contentURL[0] + : asset.contentURL; + urls.push(contentURL); + } + const id = ids[0]; + const asset = assets[id]; await rulesetFromURLS({ id: id.toLowerCase(), lang: asset.lang, name: asset.title, enabled: false, - urls: [ contentURL ], + urls, homeURL: asset.supportURL, }); } @@ -933,6 +994,13 @@ async function main() { } // Handpicked rulesets from abroad + await rulesetFromURLS({ + id: 'cname-trackers', + name: 'AdGuard CNAME-cloaked trackers', + enabled: true, + urls: [ 'https://raw.githubusercontent.com/AdguardTeam/cname-trackers/master/combined_disguised_trackers.txt' ], + homeURL: 'https://github.com/AdguardTeam/cname-trackers#cname-cloaked-trackers', + }); await rulesetFromURLS({ id: 'stevenblack-hosts', name: 'Steven Black\'s hosts file', diff --git a/platform/mv3/scriptlets/css-generic.js b/platform/mv3/scriptlets/css-generic.js new file mode 100644 index 000000000..7ded24d75 --- /dev/null +++ b/platform/mv3/scriptlets/css-generic.js @@ -0,0 +1,291 @@ +/******************************************************************************* + + uBlock Origin - a browser extension to block requests. + 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 +*/ + +/* jshint esversion:11 */ + +'use strict'; + +/******************************************************************************/ + +/// name css-generic + +/******************************************************************************/ + +// Important! +// Isolate from global scope +(function uBOL_cssGeneric() { + +/******************************************************************************/ + +// $rulesetId$ + +{ + const excludeHostnameSet = new Set(self.$excludeHostnameSet$); + + let hn; + try { hn = document.location.hostname; } catch(ex) { } + while ( hn ) { + if ( excludeHostnameSet.has(hn) ) { return; } + const pos = hn.indexOf('.'); + if ( pos === -1 ) { break; } + hn = hn.slice(pos+1); + } + excludeHostnameSet.clear(); +} + +const genericSelectorLists = new Map(self.$genericSelectorLists$); + +/******************************************************************************/ + +const queriedHashes = new Set(); +const maxSurveyTimeSlice = 4; +const styleSheetSelectors = []; +const stopAllRatio = 0.95; // To be investigated + +let surveyCount = 0; +let surveyMissCount = 0; +let styleSheetTimer; +let processTimer; +let domChangeTimer; +let lastDomChange = Date.now(); + +/******************************************************************************/ + +// https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ +const hashFromStr = (type, s) => { + const len = s.length; + const step = len + 7 >>> 3; + let hash = type; + for ( let i = 0; i < len; i += step ) { + hash = (hash << 5) - hash + s.charCodeAt(i) | 0; + } + return hash & 0x00FFFFFF; +}; + +/******************************************************************************/ + +// Extract all classes/ids: these will be passed to the cosmetic +// filtering engine, and in return we will obtain only the relevant +// CSS selectors. + +// https://github.com/gorhill/uBlock/issues/672 +// http://www.w3.org/TR/2014/REC-html5-20141028/infrastructure.html#space-separated-tokens +// http://jsperf.com/enumerate-classes/6 + +const uBOL_idFromNode = (node, out) => { + const raw = node.id; + if ( typeof raw !== 'string' || raw.length === 0 ) { return; } + const s = raw.trim(); + const hash = hashFromStr(0x23 /* '#' */, s); + if ( queriedHashes.has(hash) ) { return; } + out.push(hash); + queriedHashes.add(hash); +}; + +// https://github.com/uBlockOrigin/uBlock-issues/discussions/2076 +// Performance: avoid using Element.classList +const uBOL_classesFromNode = (node, out) => { + const s = node.getAttribute('class'); + if ( typeof s !== 'string' ) { return; } + const len = s.length; + for ( let beg = 0, end = 0, token = ''; beg < len; beg += 1 ) { + end = s.indexOf(' ', beg); + if ( end === beg ) { continue; } + if ( end === -1 ) { end = len; } + token = s.slice(beg, end); + beg = end; + const hash = hashFromStr(0x2E /* '.' */, token); + if ( queriedHashes.has(hash) ) { continue; } + out.push(hash); + queriedHashes.add(hash); + } +}; + +/******************************************************************************/ + +const pendingNodes = { + nodeLists: [], + buffer: [ + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + ], + j: 0, + add(nodes) { + if ( nodes.length === 0 ) { return; } + this.nodeLists.push(nodes); + }, + next() { + if ( this.nodeLists.length === 0 ) { return 0; } + const maxSurveyBuffer = this.buffer.length; + const nodeLists = this.nodeLists; + let ib = 0; + do { + const nodeList = nodeLists[0]; + let j = this.j; + let n = j + maxSurveyBuffer - ib; + if ( n > nodeList.length ) { + n = nodeList.length; + } + for ( let i = j; i < n; i++ ) { + this.buffer[ib++] = nodeList[j++]; + } + if ( j !== nodeList.length ) { + this.j = j; + break; + } + this.j = 0; + this.nodeLists.shift(); + } while ( ib < maxSurveyBuffer && nodeLists.length !== 0 ); + return ib; + }, + hasNodes() { + return this.nodeLists.length !== 0; + }, +}; + +/******************************************************************************/ + +const uBOL_processNodes = ( ) => { + const t0 = Date.now(); + const hashes = []; + const nodes = pendingNodes.buffer; + const deadline = t0 + maxSurveyTimeSlice; + let processed = 0; + for (;;) { + const n = pendingNodes.next(); + if ( n === 0 ) { break; } + for ( let i = 0; i < n; i++ ) { + const node = nodes[i]; + nodes[i] = null; + uBOL_idFromNode(node, hashes); + uBOL_classesFromNode(node, hashes); + } + processed += n; + if ( performance.now() >= deadline ) { break; } + } + for ( const hash of hashes ) { + const selectorList = genericSelectorLists.get(hash); + if ( selectorList === undefined ) { continue; } + styleSheetSelectors.push(selectorList); + genericSelectorLists.delete(hash); + } + surveyCount += 1; + if ( styleSheetSelectors.length === 0 ) { + surveyMissCount += 1; + if ( + surveyCount >= 100 && + (surveyMissCount / surveyCount) >= stopAllRatio + ) { + stopAll('too many misses in surveyor'); + } + return; + } + if ( styleSheetTimer !== undefined ) { return; } + styleSheetTimer = self.requestAnimationFrame(( ) => { + styleSheetTimer = undefined; + uBOL_injectStyleSheet(); + }); +}; + +/******************************************************************************/ + +const uBOL_processChanges = mutations => { + for ( let i = 0; i < mutations.length; i++ ) { + const mutation = mutations[i]; + for ( const added of mutation.addedNodes ) { + if ( added.nodeType !== 1 ) { continue; } + pendingNodes.add([ added ]); + if ( added.firstElementChild === null ) { continue; } + pendingNodes.add(added.querySelectorAll('[id],[class]')); + } + } + if ( pendingNodes.hasNodes() === false ) { return; } + lastDomChange = Date.now(); + if ( processTimer !== undefined ) { return; } + processTimer = self.setTimeout(( ) => { + processTimer = undefined; + uBOL_processNodes(); + }, 64); +}; + +/******************************************************************************/ + +const uBOL_injectStyleSheet = ( ) => { + try { + const sheet = new CSSStyleSheet(); + sheet.replace(`@layer{${styleSheetSelectors.join(',')}{display:none!important;}}`); + document.adoptedStyleSheets = [ + ...document.adoptedStyleSheets, + sheet + ]; + } catch(ex) { + } + styleSheetSelectors.length = 0; +}; + +/******************************************************************************/ + +pendingNodes.add(document.querySelectorAll('[id],[class]')); +uBOL_processNodes(); + +let domMutationObserver = new MutationObserver(uBOL_processChanges); +domMutationObserver.observe(document, { + childList: true, + subtree: true, +}); + +const needDomChangeObserver = ( ) => { + domChangeTimer = undefined; + if ( domMutationObserver === undefined ) { return; } + if ( (Date.now() - lastDomChange) > 20000 ) { + return stopAll('no more DOM changes'); + } + domChangeTimer = self.setTimeout(needDomChangeObserver, 20000); +}; + +needDomChangeObserver(); + +/******************************************************************************/ + +const stopAll = reason => { + if ( domChangeTimer !== undefined ) { + self.clearTimeout(domChangeTimer); + domChangeTimer = undefined; + } + domMutationObserver.disconnect(); + domMutationObserver.takeRecords(); + domMutationObserver = undefined; + genericSelectorLists.clear(); + queriedHashes.clear(); + console.info(`uBOL: Generic cosmetic filtering stopped because ${reason}`); +}; + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/platform/mv3/scriptlets/css-specific-procedural.js b/platform/mv3/scriptlets/css-specific-procedural.js index 7c7f22562..1fe9791d4 100644 --- a/platform/mv3/scriptlets/css-specific-procedural.js +++ b/platform/mv3/scriptlets/css-specific-procedural.js @@ -43,6 +43,10 @@ const hostnamesMap = new Map(self.$hostnamesMap$); /******************************************************************************/ +let proceduralFilterer; + +/******************************************************************************/ + const addStylesheet = text => { try { const sheet = new CSSStyleSheet(); @@ -149,11 +153,8 @@ class PSelectorMatchesMediaTask extends PSelectorTask { this.mql = window.matchMedia(task[1]); if ( this.mql.media === 'not all' ) { return; } this.mql.addEventListener('change', ( ) => { - if ( typeof vAPI !== 'object' ) { return; } - if ( vAPI === null ) { return; } - const filterer = vAPI.domFilterer && vAPI.domFilterer.proceduralFilterer; - if ( filterer instanceof Object === false ) { return; } - filterer.onDOMChanged([ null ]); + if ( proceduralFilterer instanceof Object === false ) { return; } + proceduralFilterer.onDOMChanged([ null ]); }); } transpose(node, output) { @@ -258,25 +259,10 @@ class PSelectorSpathTask extends PSelectorTask { this.spath = `:scope ${this.spath.trim()}`; } } - qsa(node) { - if ( this.nth === false ) { - return node.querySelectorAll(this.spath); - } - const parent = node.parentElement; - if ( parent === null ) { return; } - let pos = 1; - for (;;) { - node = node.previousElementSibling; - if ( node === null ) { break; } - pos += 1; - } - return parent.querySelectorAll( - `:scope > :nth-child(${pos})${this.spath}` - ); - } transpose(node, output) { - const nodes = this.qsa(node); - if ( nodes === undefined ) { return; } + const nodes = this.nth + ? PSelectorSpathTask.qsa(node, this.spath) + : node.querySelectorAll(this.spath); for ( const node of nodes ) { output.push(node); } @@ -344,10 +330,8 @@ class PSelectorWatchAttrs extends PSelectorTask { } // TODO: Is it worth trying to re-apply only the current selector? handler() { - const filterer = - vAPI.domFilterer && vAPI.domFilterer.proceduralFilterer; - if ( filterer instanceof Object ) { - filterer.onDOMChanged([ null ]); + if ( proceduralFilterer instanceof Object ) { + proceduralFilterer.onDOMChanged([ null ]); } } transpose(node, output) { @@ -420,12 +404,10 @@ class PSelector { prime(input) { const root = input || document; if ( this.selector === '' ) { return [ root ]; } - let selector = this.selector; if ( input !== document && /^ [>+~]/.test(this.selector) ) { return Array.from(PSelectorSpathTask.qsa(input, this.selector)); } - const elems = root.querySelectorAll(selector); - return Array.from(elems); + return Array.from(root.querySelectorAll(this.selector)); } exec(input) { let nodes = this.prime(input); @@ -509,7 +491,7 @@ class ProceduralFilterer { this.onDOMChanged(); } - commitNow() { + uBOL_commitNow() { //console.time('procedural selectors/dom layout changed'); // https://github.com/uBlockOrigin/uBlock-issues/issues/341 @@ -589,7 +571,7 @@ class ProceduralFilterer { if ( this.timer !== undefined ) { return; } this.timer = self.requestAnimationFrame(( ) => { this.timer = undefined; - this.commitNow(); + this.uBOL_commitNow(); }); } } @@ -668,10 +650,8 @@ if ( styleSelectors.length !== 0 ) { /******************************************************************************/ -// Procedural selectors - if ( proceduralSelectors.length !== 0 ) { - const filterer = new ProceduralFilterer(proceduralSelectors); + proceduralFilterer = new ProceduralFilterer(proceduralSelectors); const observer = new MutationObserver(mutations => { let domChanged = false; for ( let i = 0; i < mutations.length && !domChanged; i++ ) { @@ -686,7 +666,7 @@ if ( proceduralSelectors.length !== 0 ) { } } if ( domChanged === false ) { return; } - filterer.onDOMChanged(); + proceduralFilterer.onDOMChanged(); }); observer.observe(document, { childList: true, diff --git a/src/css/common.css b/src/css/common.css index 5f298a651..15f1b4fab 100644 --- a/src/css/common.css +++ b/src/css/common.css @@ -185,6 +185,9 @@ section.notice { position: relative; width: var(--checkbox-size); } +label:hover .checkbox:not([disabled]) { + background-color: var(--surface-2); + } .checkbox > input[type="checkbox"] { box-sizing: border-box; height: 100%; @@ -217,6 +220,49 @@ section.notice { filter: var(--checkbox-disabled-filter); } +.radio { + --margin-end: calc(var(--font-size) * 0.75); + box-sizing: border-box; + display: inline-flex; + flex-shrink: 0; + height: calc(var(--checkbox-size) + 2px); + margin: 0; + margin-inline-end: var(--margin-end); + -webkit-margin-end: var(--margin-end); + position: relative; + width: calc(var(--checkbox-size) + 2px); + } +.radio > input[type="radio"] { + box-sizing: border-box; + height: 100%; + margin: 0; + min-width: var(--checkbox-size); + opacity: 0; + position: absolute; + width: 100%; + } +.radio > input[type="radio"] + svg { + background-color: transparent; + box-sizing: border-box; + height: 100%; + pointer-events: none; + position: absolute; + width: 100%; + } +.radio > input[type="radio"] + svg > path { + fill: var(--checkbox-ink); + } +.radio > input[type="radio"] + svg > circle { + fill: transparent; + } +label:hover .radio > input[type="radio"]:not(:checked) + svg > circle { + fill: var(--surface-3); + } +.radio > input[type="radio"]:checked + svg > path, +.radio > input[type="radio"]:checked + svg > circle { + fill: var(--checkbox-checked-ink); + } + select { padding: 2px; } diff --git a/src/js/messaging.js b/src/js/messaging.js index 90a57ed58..7ebbdeff7 100644 --- a/src/js/messaging.js +++ b/src/js/messaging.js @@ -240,9 +240,9 @@ const onMessage = function(request, sender, callback) { isUnsupported(rule) ); out.push(`+ Unsupported filters (${bad.length}): ${JSON.stringify(bad, replacer, 2)}`); - - out.push(`\n+ Cosmetic filters: ${result.cosmetic.length}`); - for ( const details of result.cosmetic ) { + out.push(`+ generichide exclusions (${network.generichideExclusions.length}): ${JSON.stringify(network.generichideExclusions, replacer, 2)}`); + out.push(`+ Cosmetic filters: ${result.specificCosmetic.size}`); + for ( const details of result.specificCosmetic ) { out.push(` ${JSON.stringify(details)}`); } diff --git a/src/js/static-dnr-filtering.js b/src/js/static-dnr-filtering.js index 876970fcc..9347a825b 100644 --- a/src/js/static-dnr-filtering.js +++ b/src/js/static-dnr-filtering.js @@ -34,6 +34,57 @@ import { /******************************************************************************/ +// https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ + +const hashFromStr = (type, s) => { + const len = s.length; + const step = len + 7 >>> 3; + let hash = type; + for ( let i = 0; i < len; i += step ) { + hash = (hash << 5) - hash + s.charCodeAt(i) | 0; + } + return hash & 0x00FFFFFF; +}; + +/******************************************************************************/ + +// Copied from cosmetic-filter.js for the time being to avoid unwanted +// dependencies + +const rePlainSelector = /^[#.][\w\\-]+/; +const rePlainSelectorEscaped = /^[#.](?:\\[0-9A-Fa-f]+ |\\.|\w|-)+/; +const reEscapeSequence = /\\([0-9A-Fa-f]+ |.)/g; + +const keyFromSelector = selector => { + let matches = rePlainSelector.exec(selector); + if ( matches === null ) { return; } + let key = matches[0]; + if ( key.indexOf('\\') === -1 ) { + return key; + } + matches = rePlainSelectorEscaped.exec(selector); + if ( matches === null ) { return; } + key = ''; + const escaped = matches[0]; + let beg = 0; + reEscapeSequence.lastIndex = 0; + for (;;) { + matches = reEscapeSequence.exec(escaped); + if ( matches === null ) { + return key + escaped.slice(beg); + } + key += escaped.slice(beg, matches.index); + beg = reEscapeSequence.lastIndex; + if ( matches[1].length === 1 ) { + key += matches[1]; + } else { + key += String.fromCharCode(parseInt(matches[1], 16)); + } + } +}; + +/******************************************************************************/ + function addExtendedToDNR(context, parser) { if ( parser.category !== parser.CATStaticExtFilter ) { return false; } @@ -87,13 +138,35 @@ function addExtendedToDNR(context, parser) { } // Cosmetic filtering - if ( context.cosmeticFilters === undefined ) { - context.cosmeticFilters = new Map(); + + // Generic cosmetic filtering + if ( parser.hasOptions() === false ) { + if ( context.genericCosmeticFilters === undefined ) { + context.genericCosmeticFilters = new Map(); + } + const { compiled } = parser.result; + if ( compiled === undefined ) { return; } + if ( compiled.length <= 1 ) { return; } + if ( compiled.charCodeAt(0) === 0x7B /* '{' */ ) { return; } + const key = keyFromSelector(compiled); + if ( key === undefined ) { return; } + const type = key.charCodeAt(0); + const hash = hashFromStr(type, key.slice(1)); + let bucket = context.genericCosmeticFilters.get(hash); + if ( bucket === undefined ) { + context.genericCosmeticFilters.set(hash, bucket = []); + } + bucket.push(compiled); + return; } + // Specific cosmetic filtering // https://github.com/chrisaljoudi/uBlock/issues/151 // Negated hostname means the filter applies to all non-negated hostnames // of same filter OR globally if there is no non-negated hostnames. + if ( context.specificCosmeticFilters === undefined ) { + context.specificCosmeticFilters = new Map(); + } for ( const { hn, not, bad } of parser.extOptions() ) { if ( bad ) { continue; } let { compiled, exception, raw } = parser.result; @@ -107,11 +180,11 @@ function addExtendedToDNR(context, parser) { if ( rejected ) { compiled = rejected; } - let details = context.cosmeticFilters.get(compiled); + let details = context.specificCosmeticFilters.get(compiled); if ( details === undefined ) { details = {}; if ( rejected ) { details.rejected = true; } - context.cosmeticFilters.set(compiled, details); + context.specificCosmeticFilters.set(compiled, details); } if ( rejected ) { continue; } if ( not ) { @@ -206,7 +279,8 @@ async function dnrRulesetFromRawLists(lists, options = {}) { return { network: staticNetFilteringEngine.dnrFromCompiled('end', context), - cosmetic: context.cosmeticFilters, + genericCosmetic: context.genericCosmeticFilters, + specificCosmetic: context.specificCosmeticFilters, scriptlet: context.scriptletFilters, }; } diff --git a/src/js/static-net-filtering.js b/src/js/static-net-filtering.js index b61dd3415..6e102a2eb 100644 --- a/src/js/static-net-filtering.js +++ b/src/js/static-net-filtering.js @@ -4033,6 +4033,24 @@ FilterContainer.prototype.dnrFromCompiled = function(op, context, ...args) { } } + // Collect generichide filters + const generichideExclusions = []; + { + const bucket = buckets.get(AllowAction | typeNameToTypeValue['generichide']); + if ( bucket ) { + for ( const rules of bucket.values() ) { + for ( const rule of rules ) { + if ( rule.condition === undefined ) { continue; } + if ( rule.condition.initiatorDomains ) { + generichideExclusions.push(...rule.condition.initiatorDomains); + } else if ( rule.condition.requestDomains ) { + generichideExclusions.push(...rule.condition.requestDomains); + } + } + } + } + } + // Patch modifier filters for ( const rule of ruleset ) { if ( rule.__modifierType === undefined ) { continue; } @@ -4247,6 +4265,7 @@ FilterContainer.prototype.dnrFromCompiled = function(op, context, ...args) { filterCount: context.filterCount, acceptedFilterCount: context.acceptedFilterCount, rejectedFilterCount: context.rejectedFilterCount, + generichideExclusions, }; };