diff --git a/platform/mv3/extension/js/background.js b/platform/mv3/extension/js/background.js index dff432524..6b8a815c3 100644 --- a/platform/mv3/extension/js/background.js +++ b/platform/mv3/extension/js/background.js @@ -19,6 +19,8 @@ Home: https://github.com/gorhill/uBlock */ +import * as scrmgr from './scripting-manager.js'; + import { MODE_BASIC, MODE_OPTIMAL, @@ -63,6 +65,7 @@ import { browser, localRead, localRemove, localWrite, runtime, + sessionAccessLevel, webextFlavor, } from './ext.js'; @@ -99,14 +102,13 @@ import { } from './debug.js'; import { dnr } from './ext-compat.js'; -import { registerInjectables } from './scripting-manager.js'; import { toggleToolbarIcon } from './action.js'; /******************************************************************************/ const UBOL_ORIGIN = runtime.getURL('').replace(/\/$/, '').toLowerCase(); - const canShowBlockedCount = typeof dnr.setExtensionActionOptions === 'function'; +const { registerInjectables } = scrmgr; let pendingPermissionRequest; @@ -223,7 +225,7 @@ function onMessage(request, sender, callback) { switch ( request.what ) { - case 'insertCSS': { + case 'insertCSS': if ( frameId === false ) { return false; } // https://bugs.webkit.org/show_bug.cgi?id=262491 if ( frameId !== 0 && webextFlavor === 'safari' ) { return false; } @@ -235,10 +237,11 @@ function onMessage(request, sender, callback) { ubolErr(`insertCSS/${reason}`); }); return false; - } - case 'removeCSS': { + case 'removeCSS': if ( frameId === false ) { return false; } + // https://bugs.webkit.org/show_bug.cgi?id=262491 + if ( frameId !== 0 && webextFlavor === 'safari' ) { return false; } browser.scripting.removeCSS({ css: request.css, origin: 'USER', @@ -247,7 +250,6 @@ function onMessage(request, sender, callback) { ubolErr(`removeCSS/${reason}`); }); return false; - } case 'toggleToolbarIcon': { if ( tabId ) { @@ -667,6 +669,10 @@ async function startSession() { // launch time whether content css/scripts are properly registered. registerInjectables(); + // Cosmetic filtering-related content scripts cache fitlering data in + // session storage. + sessionAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' }); + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/declarativeNetRequest // Firefox API does not support `dnr.setExtensionActionOptions` if ( canShowBlockedCount ) { @@ -706,6 +712,8 @@ async function start() { if ( process.wakeupRun === false ) { await startSession(); + } else { + scrmgr.onWakeupRun(); } toggleDeveloperMode(rulesetConfig.developerMode); diff --git a/platform/mv3/extension/js/ext.js b/platform/mv3/extension/js/ext.js index 78fb2c53b..2b2164656 100644 --- a/platform/mv3/extension/js/ext.js +++ b/platform/mv3/extension/js/ext.js @@ -100,6 +100,24 @@ export async function sessionRemove(key) { return browser.storage.session.remove(key); } +export async function sessionKeys() { + if ( notAnObject(browser?.storage?.session) ) { return; } + if ( browser.storage.session.getKeys ) { + return browser.storage.session.getKeys(); + } + const bin = await browser.storage.session.get(null); + if ( notAnObject(bin) ) { return; } + return Object.keys(bin); +} + +export async function sessionAccessLevel(level) { + try { + browser.storage.session.setAccessLevel(level); + } catch { + } + +} + /******************************************************************************/ export async function adminRead(key) { diff --git a/platform/mv3/extension/js/scripting-manager.js b/platform/mv3/extension/js/scripting-manager.js index 43b02efc9..440e641e2 100644 --- a/platform/mv3/extension/js/scripting-manager.js +++ b/platform/mv3/extension/js/scripting-manager.js @@ -21,7 +21,11 @@ import * as ut from './utils.js'; -import { browser, localRemove } from './ext.js'; +import { + browser, + localKeys, localRemove, localWrite, + sessionKeys, sessionRead, sessionRemove, sessionWrite, +} from './ext.js'; import { ubolErr, ubolLog } from './debug.js'; import { fetchJSON } from './fetch.js'; @@ -90,6 +94,15 @@ const normalizeRegisteredContentScripts = registered => { /******************************************************************************/ +async function resetCSSCache() { + const keys = await sessionKeys(); + return Promise.all( + keys.filter(a => a.startsWith('cache.css.')).map(a => sessionRemove(a)) + ); +} + +/******************************************************************************/ + function registerHighGeneric(context, genericDetails) { const { before, filteringModeDetails, rulesetsDetails } = context; @@ -286,16 +299,24 @@ function registerGeneric(context, genericDetails) { /******************************************************************************/ -function registerProcedural(context) { +async function registerCosmetic(realm, context) { const { before, filteringModeDetails, rulesetsDetails } = context; - const js = []; - for ( const rulesetDetails of rulesetsDetails ) { - const count = rulesetDetails.css?.procedural || 0; - if ( count === 0 ) { continue; } - js.push(`/rulesets/scripting/procedural/${rulesetDetails.id}.js`); + { + const keys = await localKeys(); + for ( const key of keys ) { + if ( key.startsWith(`css.${realm}.`) === false ) { continue; } + localRemove(key); + } } - if ( js.length === 0 ) { return; } + + const rulesetIds = []; + for ( const rulesetDetails of rulesetsDetails ) { + const count = rulesetDetails.css?.[realm] || 0; + if ( count === 0 ) { continue; } + rulesetIds.push(rulesetDetails.id); + } + if ( rulesetIds.length === 0 ) { return; } const { none, basic, optimal, complete } = filteringModeDetails; const matches = [ @@ -304,10 +325,24 @@ function registerProcedural(context) { ]; if ( matches.length === 0 ) { return; } + { + const promises = []; + for ( const id of rulesetIds ) { + promises.push( + fetchJSON(`/rulesets/scripting/${realm}/${id}`).then(data => { + return localWrite(`css.${realm}.${id}`, data); + }) + ); + } + await Promise.all(promises); + } + normalizeMatches(matches); + const realmid = `css-${realm}`; + const js = rulesetIds.map(id => `/rulesets/scripting/${realm}/${id}.js`); js.unshift('/js/scripting/css-api.js', '/js/scripting/isolated-api.js'); - js.push('/js/scripting/css-procedural.js'); + js.push(`/js/scripting/${realmid}.js`); const excludeMatches = []; if ( none.has('all-urls') === false && basic.has('all-urls') === false ) { @@ -320,11 +355,11 @@ function registerProcedural(context) { } } - const registered = before.get('css-procedural'); - before.delete('css-procedural'); // Important! + const registered = before.get(realmid); + before.delete(realmid); // Important! const directive = { - id: 'css-procedural', + id: realmid, js, matches, allFrames: true, @@ -346,71 +381,7 @@ function registerProcedural(context) { ut.strArrayEq(registered.matches, matches) === false || ut.strArrayEq(registered.excludeMatches, excludeMatches) === false ) { - context.toRemove.push('css-procedural'); - context.toAdd.push(directive); - } -} - -/******************************************************************************/ - -function registerSpecific(context) { - const { before, filteringModeDetails, rulesetsDetails } = context; - - const js = []; - for ( const rulesetDetails of rulesetsDetails ) { - const count = rulesetDetails.css?.specific || 0; - if ( count === 0 ) { continue; } - js.push(`/rulesets/scripting/specific/${rulesetDetails.id}.js`); - } - if ( js.length === 0 ) { return; } - - const { none, basic, optimal, complete } = filteringModeDetails; - const matches = [ - ...ut.matchesFromHostnames(optimal), - ...ut.matchesFromHostnames(complete), - ]; - if ( matches.length === 0 ) { return; } - - normalizeMatches(matches); - - js.unshift('/js/scripting/css-api.js', '/js/scripting/isolated-api.js'); - js.push('/js/scripting/css-specific.js'); - - const excludeMatches = []; - if ( none.has('all-urls') === false ) { - excludeMatches.push(...ut.matchesFromHostnames(none)); - } - if ( basic.has('all-urls') === false ) { - excludeMatches.push(...ut.matchesFromHostnames(basic)); - } - - const registered = before.get('css-specific'); - before.delete('css-specific'); // Important! - - const directive = { - id: 'css-specific', - js, - matches, - allFrames: true, - runAt: 'document_start', - }; - if ( excludeMatches.length !== 0 ) { - directive.excludeMatches = excludeMatches; - } - - // register - if ( registered === undefined ) { - context.toAdd.push(directive); - return; - } - - // update - if ( - ut.strArrayEq(registered.js, js, false) === false || - ut.strArrayEq(registered.matches, matches) === false || - ut.strArrayEq(registered.excludeMatches, excludeMatches) === false - ) { - context.toRemove.push('css-specific'); + context.toRemove.push(realmid); context.toAdd.push(directive); } } @@ -499,7 +470,7 @@ function registerScriptlet(context, scriptletDetails) { // Issue: Safari appears to completely ignore excludeMatches // https://github.com/radiolondra/ExcludeMatches-Test -async function registerInjectables() { +export async function registerInjectables() { if ( browser.scripting === undefined ) { return false; } if ( registerInjectables.barrier ) { return true; } @@ -533,9 +504,9 @@ async function registerInjectables() { }; await Promise.all([ - registerProcedural(context), registerScriptlet(context, scriptletDetails), - registerSpecific(context), + registerCosmetic('specific', context), + registerCosmetic('procedural', context), registerGeneric(context, genericDetails), registerHighGeneric(context, genericDetails), registerCustomFilters(context), @@ -564,6 +535,8 @@ async function registerInjectables() { } } + await resetCSSCache(); + registerInjectables.barrier = false; return true; @@ -571,6 +544,25 @@ async function registerInjectables() { /******************************************************************************/ -export { - registerInjectables -}; +export async function onWakeupRun() { + const cleanupTime = await sessionRead('scripting.manager.cleanup.time') || 0; + const now = Date.now(); + const since = now - cleanupTime; + if ( since < (15 * 60 * 1000) ) { return; } // 15 minutes + const MAX_CACHE_ENTRY_LOW = 256; + const MAX_CACHE_ENTRY_HIGH = MAX_CACHE_ENTRY_LOW + + Math.min(Math.round(MAX_CACHE_ENTRY_LOW + MAX_CACHE_ENTRY_LOW / 8), 1); + const keys = await sessionKeys() || []; + const cacheKeys = keys.filter(a => a.startsWith('cache.css.')); + if ( cacheKeys.length < MAX_CACHE_ENTRY_HIGH ) { return; } + const entries = await Promise.all(cacheKeys.map(async a => { + const entry = await sessionRead(a) || {}; + entry.key = a; + return entry; + })); + entries.sort((a, b) => b.t - a.t); + entries.slice(MAX_CACHE_ENTRY_LOW).map(a => sessionRemove(a.key)); + sessionWrite('scripting.manager.cleanup.time', now) +} + +/******************************************************************************/ diff --git a/platform/mv3/extension/js/scripting/css-procedural.js b/platform/mv3/extension/js/scripting/css-procedural.js index a17da0020..0a7769d93 100644 --- a/platform/mv3/extension/js/scripting/css-procedural.js +++ b/platform/mv3/extension/js/scripting/css-procedural.js @@ -21,7 +21,7 @@ // Important! // Isolate from global scope -(function uBOL_cssProcedural() { +(async function uBOL_cssProcedural() { /******************************************************************************/ @@ -30,49 +30,13 @@ self.proceduralImports = undefined; /******************************************************************************/ -const isolatedAPI = self.isolatedAPI; -const selectors = new Set(); -const exceptions = new Set(); - -const lookupHostname = (hostname, details) => { - const listref = isolatedAPI.binarySearch(details.hostnames, hostname); - if ( listref === -1 ) { return; } - if ( Array.isArray(details.selectorLists) === false ) { - details.selectorLists = details.selectorLists.split(';'); - details.selectorListRefs = JSON.parse(`[${details.selectorListRefs}]`); - } - const ilist = details.selectorListRefs[listref]; - const list = JSON.parse(`[${details.selectorLists[ilist]}]`); - for ( const iselector of list ) { - if ( iselector >= 0 ) { - selectors.add(details.selectors[iselector]); - } else { - exceptions.add(details.selectors[~iselector]); - } - } -}; - -const lookupAll = hostname => { - for ( const details of proceduralImports ) { - lookupHostname(hostname, details); - } -}; - -isolatedAPI.forEachHostname(lookupAll, { - hasEntities: proceduralImports.some(a => a.hasEntities) -}); +const selectors = await self.cosmeticAPI.getSelectors('procedural', proceduralImports); +self.cosmeticAPI.release(); +if ( selectors.length === 0 ) { return; } proceduralImports.length = 0; -for ( const selector of exceptions ) { - selectors.delete(selector); -} - -if ( selectors.size === 0 ) { return; } - -const exceptedSelectors = Array.from(selectors).map(a => JSON.parse(a)); - -const declaratives = exceptedSelectors.filter(a => a.cssable); +const declaratives = selectors.filter(a => a.cssable); if ( declaratives.length !== 0 ) { const cssRuleFromProcedural = details => { const { tasks, action } = details; @@ -112,7 +76,7 @@ if ( declaratives.length !== 0 ) { } } -const procedurals = exceptedSelectors.filter(a => a.cssable === undefined); +const procedurals = selectors.filter(a => a.cssable === undefined); if ( procedurals.length !== 0 ) { const addSelectors = selectors => { if ( self.listsProceduralFiltererAPI instanceof Object === false ) { return; } diff --git a/platform/mv3/extension/js/scripting/css-specific.js b/platform/mv3/extension/js/scripting/css-specific.js index 4f2c878e1..10de503f9 100644 --- a/platform/mv3/extension/js/scripting/css-specific.js +++ b/platform/mv3/extension/js/scripting/css-specific.js @@ -21,7 +21,7 @@ // Important! // Isolate from global scope -(function uBOL_cssSpecific() { +(async function uBOL_cssSpecific() { /******************************************************************************/ @@ -30,48 +30,10 @@ self.specificImports = undefined; /******************************************************************************/ -const isolatedAPI = self.isolatedAPI; -const selectors = new Set(); -const exceptions = new Set(); - -const lookupHostname = (hostname, details) => { - const listref = isolatedAPI.binarySearch(details.hostnames, hostname); - if ( listref === -1 ) { return; } - if ( Array.isArray(details.selectorLists) === false ) { - details.selectorLists = details.selectorLists.split(';'); - details.selectorListRefs = JSON.parse(`[${details.selectorListRefs}]`); - } - const ilist = details.selectorListRefs[listref]; - const list = JSON.parse(`[${details.selectorLists[ilist]}]`); - for ( const iselector of list ) { - if ( iselector >= 0 ) { - selectors.add(details.selectors[iselector]); - } else { - exceptions.add(details.selectors[~iselector]); - } - } -}; - -const lookupAll = hostname => { - for ( const details of specificImports ) { - lookupHostname(hostname, details); - } -}; - -isolatedAPI.forEachHostname(lookupAll, { - hasEntities: specificImports.some(a => a.hasEntities) -}); - -specificImports.length = 0; - -for ( const selector of exceptions ) { - selectors.delete(selector); -} - -if ( selectors.size === 0 ) { return; } - -const css = `${Array.from(selectors).join(',\n')}{display:none!important;}`; -self.cssAPI.insert(css); +const selectors = await self.cosmeticAPI.getSelectors('specific', specificImports); +self.cosmeticAPI.release(); +if ( selectors.length === 0 ) { return; } +self.cssAPI.insert(`${selectors.join(',\n')}{display:none!important;}`); /******************************************************************************/ diff --git a/platform/mv3/extension/js/scripting/isolated-api.js b/platform/mv3/extension/js/scripting/isolated-api.js index 4cf251548..49c54064c 100644 --- a/platform/mv3/extension/js/scripting/isolated-api.js +++ b/platform/mv3/extension/js/scripting/isolated-api.js @@ -74,7 +74,38 @@ } }; - isolatedAPI.binarySearch = (sorted, target) => { +})(self.isolatedAPI); + + +(api => { + if ( typeof api === 'object' ) { return; } + + const cosmeticAPI = self.cosmeticAPI = {}; + + const sessionRead = async function(key) { + try { + const bin = await chrome.storage.session.get(key); + return bin?.[key] ?? undefined; + } catch { + } + }; + + const sessionWrite = function(key, data) { + try { + chrome.storage.session.set({ [key]: data }); + } catch { + } + }; + + const localRead = async function(key) { + try { + const bin = await chrome.storage.local.get(key); + return bin?.[key] ?? undefined; + } catch { + } + }; + + const binarySearch = (sorted, target) => { let l = 0, i = 0, d = 0; let r = sorted.length; let candidate; @@ -95,7 +126,79 @@ return -1; }; -})(self.isolatedAPI); + const lookupHostname = (hostname, data) => { + const listref = binarySearch(data.hostnames, hostname); + if ( listref === -1 ) { return; } + const ilist = data.selectorListRefs[listref]; + const list = JSON.parse(`[${data.selectorLists[ilist]}]`); + const { result } = data; + for ( const iselector of list ) { + if ( iselector >= 0 ) { + result.selectors.add(data.selectors[iselector]); + } else { + result.exceptions.add(data.selectors[~iselector]); + } + } + }; + + const selectorsFromRuleset = async (realm, rulesetId, result) => { + const data = await localRead(`css.${realm}.${rulesetId}`); + if ( typeof data !== 'object' || data === null ) { return; } + data.result = result; + self.isolatedAPI.forEachHostname(lookupHostname, data); + }; + + const fillCache = async function(realm, rulesetIds) { + const selectors = new Set(); + const exceptions = new Set(); + const result = { selectors, exceptions }; + await Promise.all(rulesetIds.map(a => selectorsFromRuleset(realm, a, result))); + for ( const selector of exceptions ) { + selectors.delete(selector); + } + cacheEntry[cacheSlots[realm]] = Array.from(selectors).map(a => + a.startsWith('{') ? JSON.parse(a) : a + ); + }; + + const readCache = async ( ) => { + cacheEntry = await sessionRead(cacheKey) || {}; + }; + + const cacheSlots = { 'specific': 's', 'procedural': 'p' }; + const cacheKey = `cache.css.${document.location.hostname || ''}`; + let clientCount = 0; + let cacheEntry; + + cosmeticAPI.getSelectors = async function(realm, rulesetIds) { + clientCount += 1; + const slot = cacheSlots[realm]; + if ( cacheEntry === undefined ) { + cacheEntry = readCache(); + } + if ( cacheEntry instanceof Promise ) { + await cacheEntry; + } + if ( cacheEntry[slot] === undefined ) { + cacheEntry[slot] = fillCache(realm, rulesetIds); + } + if ( cacheEntry[slot] instanceof Promise ) { + await cacheEntry[slot]; + } + return cacheEntry[slot]; + }; + + cosmeticAPI.release = function() { + clientCount -= 1; + if ( clientCount !== 0 ) { return; } + self.cosmeticAPI = undefined; + const now = Math.round(Date.now() / 15000); + const since = now - (cacheEntry.t || 0); + if ( since <= 1 ) { return; } + cacheEntry.t = now; + sessionWrite(cacheKey, cacheEntry); + }; +})(self.cosmeticAPI); /******************************************************************************/ diff --git a/platform/mv3/make-rulesets.js b/platform/mv3/make-rulesets.js index 9443e5ac9..65a1dfc41 100644 --- a/platform/mv3/make-rulesets.js +++ b/platform/mv3/make-rulesets.js @@ -863,38 +863,28 @@ async function processCosmeticFilters(assetDetails, realm, mapin) { allHostnames.set(hn, allSelectorLists.get(list)); } - // The cosmetic filters will be injected programmatically as content - // script and the decisions to activate the cosmetic filters will be - // done at injection time according to the document's hostname. - const originalScriptletMap = await loadAllSourceScriptlets(); - let patchedScriptlet = originalScriptletMap.get(`css-${realm}`).replace( - '$rulesetId$', - assetDetails.id - ); - patchedScriptlet = safeReplace(patchedScriptlet, - /\bself\.\$selectors\$/, - `/* ${allSelectors.size} */ ${JSON.stringify(Array.from(allSelectors.keys()))}` - ); - patchedScriptlet = safeReplace(patchedScriptlet, - /\bself\.\$selectorLists\$/, - `/* ${allSelectorLists.size} */ ${JSON.stringify(Array.from(allSelectorLists.keys()).join(';'))}` - ); const sortedHostnames = Array.from(allHostnames.keys()).toSorted((a, b) => { const d = a.length - b.length; if ( d !== 0 ) { return d; } return a < b ? -1 : 1; }); - patchedScriptlet = safeReplace(patchedScriptlet, - /\bself\.\$selectorListRefs\$/, - `/* ${sortedHostnames.length} */ "${JSON.stringify(sortedHostnames.map(a => allHostnames.get(a))).slice(1, -1)}"` - ); - patchedScriptlet = safeReplace(patchedScriptlet, - /\bself\.\$hostnames\$/, - `/* ${sortedHostnames.length} */ ${JSON.stringify(sortedHostnames)}` - ); - patchedScriptlet = safeReplace(patchedScriptlet, - 'self.$hasEntities$', - JSON.stringify(hasEntities) + + const data = JSON.stringify({ + selectors: Array.from(allSelectors.keys()), + selectorLists: Array.from(allSelectorLists.keys()), + selectorListRefs: sortedHostnames.map(a => allHostnames.get(a)), + hostnames: sortedHostnames, + hasEntities, + }); + writeFile(`${scriptletDir}/${realm}/${assetDetails.id}.json`, data); + + // The cosmetic filters will be injected programmatically as content + // script and the decisions to activate the cosmetic filters will be + // done at injection time according to the document's hostname. + const originalScriptletMap = await loadAllSourceScriptlets(); + let patchedScriptlet = originalScriptletMap.get(`css-${realm}`).replace( + 'self.$rulesetId$', + JSON.stringify(assetDetails.id) ); writeFile(`${scriptletDir}/${realm}/${assetDetails.id}.js`, patchedScriptlet); diff --git a/platform/mv3/scriptlets/css-procedural.template.js b/platform/mv3/scriptlets/css-procedural.template.js index 1456dc1be..0aaf6c67f 100644 --- a/platform/mv3/scriptlets/css-procedural.template.js +++ b/platform/mv3/scriptlets/css-procedural.template.js @@ -19,23 +19,16 @@ Home: https://github.com/gorhill/uBlock */ -// ruleset: $rulesetId$ - // Important! // Isolate from global scope (function uBOL_cssProceduralImport() { /******************************************************************************/ - -const selectors = self.$selectors$; -const selectorLists = self.$selectorLists$; -const selectorListRefs = self.$selectorListRefs$; -const hostnames = self.$hostnames$; -const hasEntities = self.$hasEntities$; +const rulesetId = self.$rulesetId$; self.proceduralImports = self.proceduralImports || []; -self.proceduralImports.push({ selectors, selectorLists, selectorListRefs, hostnames, hasEntities }); +self.proceduralImports.push(rulesetId); /******************************************************************************/ diff --git a/platform/mv3/scriptlets/css-specific.template.js b/platform/mv3/scriptlets/css-specific.template.js index cfe84f385..7630892fb 100644 --- a/platform/mv3/scriptlets/css-specific.template.js +++ b/platform/mv3/scriptlets/css-specific.template.js @@ -19,22 +19,16 @@ Home: https://github.com/gorhill/uBlock */ -// ruleset: $rulesetId$ - // Important! // Isolate from global scope (function uBOL_cssSpecificImports() { /******************************************************************************/ -const selectors = self.$selectors$; -const selectorLists = self.$selectorLists$; -const selectorListRefs = self.$selectorListRefs$; -const hostnames = self.$hostnames$; -const hasEntities = self.$hasEntities$; +const rulesetId = self.$rulesetId$; self.specificImports = self.specificImports || []; -self.specificImports.push({ selectors, selectorLists, selectorListRefs, hostnames, hasEntities }); +self.specificImports.push(rulesetId); /******************************************************************************/