diff --git a/src/background/adblocker.js b/src/background/adblocker.js index 2b874243e..2bd530741 100644 --- a/src/background/adblocker.js +++ b/src/background/adblocker.js @@ -16,10 +16,11 @@ import { } from '@ghostery/adblocker-webextension'; import { parse } from 'tldts-experimental'; -import Options, { observe, ENGINES, isPaused } from '/store/options.js'; +import Options, { ENGINES, isPaused } from '/store/options.js'; import * as engines from '/utils/engines.js'; import * as trackerdb from '/utils/trackerdb.js'; +import * as OptionsObserver from '/utils/options-observer.js'; import Request from '/utils/request.js'; import asyncSetup from '/utils/setup.js'; import { debugMode } from '/utils/debug.js'; @@ -124,41 +125,46 @@ async function updateEngines() { const HOUR_IN_MS = 60 * 60 * 1000; export const setup = asyncSetup([ - observe(async (value, lastValue) => { - options = value; - - const enabledEngines = getEnabledEngines(value); - const prevEnabledEngines = lastValue && getEnabledEngines(lastValue); - - if ( - // Reload/mismatched main engine - !(await engines.init(engines.MAIN_ENGINE)) || - // Enabled engines changed - (prevEnabledEngines && - (enabledEngines.length !== prevEnabledEngines.length || - enabledEngines.some((id, i) => id !== prevEnabledEngines[i]))) - ) { - // The regional filters engine is no longer used, so we must remove it - // from the storage. We do it as rarely as possible, to avoid unnecessary loads. - // TODO: this can be removed in the future release when most of the users will have - // the new version of the extension - engines.remove('regional-filters'); - - await reloadMainEngine(); - } - - if (options.filtersUpdatedAt < Date.now() - HOUR_IN_MS) { - await updateEngines(); - } - }), - observe('experimentalFilters', async (value, lastValue) => { - engines.setEnv('env_experimental', value); + OptionsObserver.addListener( + async function adblockerEngines(value, lastValue) { + options = value; + + const enabledEngines = getEnabledEngines(value); + const prevEnabledEngines = lastValue && getEnabledEngines(lastValue); + + if ( + // Reload/mismatched main engine + !(await engines.init(engines.MAIN_ENGINE)) || + // Enabled engines changed + (prevEnabledEngines && + (enabledEngines.length !== prevEnabledEngines.length || + enabledEngines.some((id, i) => id !== prevEnabledEngines[i]))) + ) { + // The regional filters engine is no longer used, so we must remove it + // from the storage. We do it as rarely as possible, to avoid unnecessary loads. + // TODO: this can be removed in the future release when most of the users will have + // the new version of the extension + engines.remove('regional-filters'); + + await reloadMainEngine(); + } - // Experimental filters changed to enabled - if (lastValue !== undefined && value) { - await updateEngines(); - } - }), + if (options.filtersUpdatedAt < Date.now() - HOUR_IN_MS) { + await updateEngines(); + } + }, + ), + OptionsObserver.addListener( + 'experimentalFilters', + async (value, lastValue) => { + engines.setEnv('env_experimental', value); + + // Experimental filters changed to enabled + if (lastValue !== undefined && value) { + await updateEngines(); + } + }, + ), ]); function adblockerInjectStylesWebExtension( diff --git a/src/background/custom-filters.js b/src/background/custom-filters.js index c9a86aed2..b2ef544f6 100644 --- a/src/background/custom-filters.js +++ b/src/background/custom-filters.js @@ -22,8 +22,9 @@ import { createOffscreenConverter, } from '/utils/dnr-converter.js'; import * as engines from '/utils/engines.js'; +import * as OptionsObserver from '/utils/options-observer.js'; -import Options, { observe } from '/store/options.js'; +import Options from '/store/options.js'; import CustomFilters from '/store/custom-filters.js'; import { setup } from '/background/adblocker.js'; @@ -209,7 +210,9 @@ async function update(text, { trustedScriptlets }) { return result; } -observe('customFilters', async ({ enabled, trustedScriptlets }, lastValue) => { +OptionsObserver.addListener('customFilters', async (value, lastValue) => { + const { enabled, trustedScriptlets } = value; + // Background startup if (!lastValue) { // If custom filters are disabled, we don't care if engine was reloaded @@ -218,7 +221,9 @@ observe('customFilters', async ({ enabled, trustedScriptlets }, lastValue) => { // If we cannot initialize engine, we need to update it if (!(await engines.init(engines.CUSTOM_ENGINE))) { - update((await store.resolve(CustomFilters)).text, { trustedScriptlets }); + update((await store.resolve(CustomFilters)).text, { + trustedScriptlets, + }); } } else { // If only trustedScriptlets has changed, we don't update automatically. diff --git a/src/background/dnr.js b/src/background/dnr.js index b180c299b..a10b25fd2 100644 --- a/src/background/dnr.js +++ b/src/background/dnr.js @@ -9,7 +9,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0 */ -import { observe, ENGINES, isPaused } from '/store/options.js'; +import { ENGINES, isPaused } from '/store/options.js'; +import * as OptionsObserver from '/utils/options-observer.js'; if (__PLATFORM__ === 'chromium' || __PLATFORM__ === 'safari') { const DNR_RESOURCES = chrome.runtime @@ -20,7 +21,7 @@ if (__PLATFORM__ === 'chromium' || __PLATFORM__ === 'safari') { // Ensure that DNR rulesets are equal to those from options. // eg. when web extension updates, the rulesets are reset // to the value from the manifest. - observe(async (options) => { + OptionsObserver.addListener(async function dnr(options) { const globalPause = isPaused(options); const ids = ENGINES.map(({ name, key }) => { diff --git a/src/background/helpers.js b/src/background/helpers.js index 68ff0976c..6f5a7d659 100644 --- a/src/background/helpers.js +++ b/src/background/helpers.js @@ -9,7 +9,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0 */ -import { idleOptionsObservers } from '/store/options.js'; +import * as OptionsObserver from '/utils/options-observer.js'; chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { switch (msg.action) { @@ -35,7 +35,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { break; // This is used only by the e2e tests to detect idle state case 'idleOptionsObservers': { - idleOptionsObservers.then(() => { + OptionsObserver.waitForIdle().then(() => { sendResponse('done'); console.info('[helpers] "idleOptionsObservers" response...'); }); diff --git a/src/background/index.js b/src/background/index.js index 04c5a3525..87fc23f25 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -25,7 +25,7 @@ import './serp.js'; import './helpers.js'; import './external.js'; -import './telemetry/index.js'; import './reporting/index.js'; +import './telemetry/index.js'; import './devtools.js'; diff --git a/src/background/onboarding.js b/src/background/onboarding.js index eeab6b0ca..677fbab81 100644 --- a/src/background/onboarding.js +++ b/src/background/onboarding.js @@ -8,9 +8,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0 */ -import { observe } from '/store/options.js'; +import * as OptionsObserver from '/utils/options-observer.js'; -observe('onboarding', (onboarding) => { +OptionsObserver.addListener('onboarding', (onboarding) => { if (!onboarding.shown) { chrome.tabs.create({ url: chrome.runtime.getURL('/pages/onboarding/index.html'), diff --git a/src/background/paused.js b/src/background/paused.js index c4a919fce..cbfead94d 100644 --- a/src/background/paused.js +++ b/src/background/paused.js @@ -10,7 +10,9 @@ */ import { store } from 'hybrids'; -import Options, { observe, GLOBAL_PAUSE_ID } from '/store/options.js'; + +import Options, { GLOBAL_PAUSE_ID } from '/store/options.js'; +import * as OptionsObserver from '/utils/options-observer.js'; // Pause / unpause hostnames const PAUSED_ALARM_PREFIX = 'options:revoke'; @@ -33,7 +35,7 @@ const ALL_RESOURCE_TYPES = [ 'other', ]; -observe('paused', async (paused, prevPaused) => { +OptionsObserver.addListener('paused', async (paused, prevPaused) => { const alarms = (await chrome.alarms.getAll()).filter(({ name }) => name.startsWith(PAUSED_ALARM_PREFIX), ); @@ -127,12 +129,12 @@ observe('paused', async (paused, prevPaused) => { ], removeRuleIds, }); - console.log('DNR: pause rules updated'); + console.log('[dnr] pause rules updated'); } else if (removeRuleIds.length) { await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds, }); - console.log('DNR: pause rules updated'); + console.log('[dnr] pause rules updated'); } } }); diff --git a/src/background/reporting/index.js b/src/background/reporting/index.js index d9e59cfe4..3c984b80c 100644 --- a/src/background/reporting/index.js +++ b/src/background/reporting/index.js @@ -14,10 +14,10 @@ import './webrequest-monkey-patch.js'; import { setLogLevel, describeLoggers } from '@whotracksme/reporting/reporting'; -import { observe } from '/store/options.js'; - import asyncSetup from '/utils/setup.js'; import debug from '/utils/debug.js'; +import * as OptionsObserver from '/utils/options-observer.js'; + import config from './config.js'; import communication from './communication.js'; import urlReporter from './url-reporter.js'; @@ -40,7 +40,7 @@ import webRequestReporter from './webrequest-reporter.js'; })(); const setup = asyncSetup([ - observe('terms', async (terms) => { + OptionsObserver.addListener('terms', async function reporting(terms) { if (terms) { await urlReporter.init().catch((e) => { console.warn( diff --git a/src/background/reporting/webrequest-reporter.js b/src/background/reporting/webrequest-reporter.js index fa1c015a8..a14fb9a25 100644 --- a/src/background/reporting/webrequest-reporter.js +++ b/src/background/reporting/webrequest-reporter.js @@ -15,8 +15,9 @@ import { } from '@whotracksme/reporting/reporting'; import getBrowserInfo from '/utils/browser-info.js'; -import { observe, isPaused } from '/store/options.js'; +import { isPaused } from '/store/options.js'; import Request from '/utils/request.js'; +import * as OptionsObserver from '/utils/options-observer.js'; import { updateTabStats } from '../stats.js'; @@ -33,7 +34,7 @@ if (__PLATFORM__ === 'chromium' || __PLATFORM__ === 'firefox') { webRequestPipeline.init(); let options = {}; - observe((value) => { + OptionsObserver.addListener(function webRequestReporting(value) { options = value; }); diff --git a/src/background/stats.js b/src/background/stats.js index 7c5e63f0e..7f66360f6 100644 --- a/src/background/stats.js +++ b/src/background/stats.js @@ -15,9 +15,10 @@ import { getOffscreenImageData } from '/ui/wheel.js'; import { order } from '/ui/categories.js'; import DailyStats from '/store/daily-stats.js'; -import Options, { isPaused, observe } from '/store/options.js'; +import Options, { isPaused } from '/store/options.js'; import { isSerpSupported } from '/utils/opera.js'; +import * as OptionsObserver from '/utils/options-observer.js'; import AutoSyncingMap from '/utils/map.js'; import { getMetadata, getUnidentifiedTracker } from '/utils/trackerdb.js'; @@ -40,7 +41,7 @@ function setBadgeColor(color = '#3f4146' /* gray-600 */) { chromeAction.setBadgeBackgroundColor({ color }); } -observe('terms', async (terms) => { +OptionsObserver.addListener('terms', async function stats(terms) { if (!terms) { await chromeAction.setBadgeText({ text: '!' }); setBadgeColor('#f13436' /* danger-500 */); diff --git a/src/background/telemetry/index.js b/src/background/telemetry/index.js index 4231a03e4..3cecf9c2c 100644 --- a/src/background/telemetry/index.js +++ b/src/background/telemetry/index.js @@ -11,48 +11,35 @@ import { store } from 'hybrids'; -import Options, { observe } from '/store/options.js'; +import Options from '/store/options.js'; import { debugMode } from '/utils/debug.js'; +import asyncSetup from '/utils/setup.js'; +import * as OptionsObserver from '/utils/options-observer.js'; -import Telemetry from './metrics.js'; +import Metrics from './metrics.js'; -const log = console.log.bind(console, '[telemetry]'); - -async function recordUTMs(telemetry, JUST_INSTALLED) { - if (JUST_INSTALLED) { - const { utm_source, utm_campaign } = await telemetry.detectUTMs(); - // persist campaign & source only - await chrome.storage.local.set({ utms: { utm_campaign, utm_source } }); - return; - } - - const { utms = {} } = await chrome.storage.local.get(['utms']); - - telemetry.setUTMs(utms); -} - -const saveStorage = async (storage, metrics) => { - Object.assign(storage, metrics); - await chrome.storage.local.set({ metrics: storage }); -}; - -const loadStorage = async () => { +async function loadStorage() { const storage = { active_daily_velocity: [], engaged_daily_velocity: [], engaged_daily_count: [], }; - Telemetry.FREQUENCY_TYPES.forEach((frequency) => { - Telemetry.CRITICAL_TYPES.forEach((type) => { + Metrics.FREQUENCY_TYPES.forEach((frequency) => { + Metrics.CRITICAL_TYPES.forEach((type) => { storage[`${type}_${frequency}`] = 0; }); }); const { metrics = {} } = await chrome.storage.local.get(['metrics']); Object.assign(storage, metrics); return storage; -}; +} -const getConf = async (storage) => { +async function saveStorage(storage, metrics) { + Object.assign(storage, metrics); + await chrome.storage.local.set({ metrics: storage }); +} + +async function getConf(storage) { const options = await store.resolve(Options); // Historically install_data was stored in Options. @@ -76,52 +63,55 @@ const getConf = async (storage) => { installRandom: storage.installRandom, setup_shown: options.onboarding.shown, }; -}; +} -let telemetry; -let telemetryEnabled = false; +let metrics; +const setup = asyncSetup([ + (async () => { + const storage = await loadStorage(); + const { version } = chrome.runtime.getManifest(); + + metrics = new Metrics({ + METRICS_BASE_URL: debugMode + ? 'https://staging-d.ghostery.com' + : 'https://d.ghostery.com', + EXTENSION_VERSION: version, + getConf: () => getConf(storage), + log: console.log.bind(console, '[telemetry]'), + storage, + saveStorage: (metrics) => { + saveStorage(storage, metrics); + }, + }); -chrome.runtime.onMessage.addListener((msg) => { - if (telemetryEnabled && msg.action === 'telemetry') { - telemetry.ping(msg.event); - } -}); + if (metrics.isJustInstalled()) { + const utms = await metrics.detectUTMs(); + await chrome.storage.local.set({ utms }); + } else { + const { utms = {} } = await chrome.storage.local.get(['utms']); + metrics.setUTMs(utms); + } -(async () => { - const storage = await loadStorage(); - const { version } = chrome.runtime.getManifest(); - - telemetry = new Telemetry({ - METRICS_BASE_URL: debugMode - ? 'https://staging-d.ghostery.com' - : 'https://d.ghostery.com', - EXTENSION_VERSION: version, - getConf: () => getConf(storage), - log, - storage, - saveStorage: (metrics) => { - saveStorage(storage, metrics); - }, - }); + metrics.setUninstallUrl(); + })(), +]); - const JUST_INSTALLED = storage.install_all === 0; +let enabled = false; +OptionsObserver.addListener('terms', async function telemetry(terms) { + enabled = terms; - try { - await recordUTMs(telemetry, JUST_INSTALLED); - } catch (error) { - log('Telemetry recordUTMs() error', error); - } + if (terms) { + setup.pending && (await setup.pending); - observe('terms', async (terms) => { - telemetryEnabled = terms; - if (!terms) { - return; + if (metrics.isJustInstalled()) { + metrics.ping('install'); } - telemetry.ping('active'); - if (JUST_INSTALLED) { - telemetry.ping('install'); - } - }); + metrics.ping('active'); + } +}); - telemetry.setUninstallUrl(); -})(); +chrome.runtime.onMessage.addListener((msg) => { + if (enabled && msg.action === 'telemetry') { + Promise.resolve(setup.pending).then(() => metrics.ping(msg.event)); + } +}); diff --git a/src/background/telemetry/metrics.js b/src/background/telemetry/metrics.js index 2a92fea54..1e29aafea 100644 --- a/src/background/telemetry/metrics.js +++ b/src/background/telemetry/metrics.js @@ -127,6 +127,13 @@ class Metrics { this.utm_source = utm_source; this.utm_campaign = utm_campaign; } + /** + * Check if the extension was just installed + * @returns {boolean} true if the extension was just installed + */ + isJustInstalled() { + return !this.storage.install_all; + } /** * Prepare data and send telemetry pings. @@ -556,11 +563,9 @@ class Metrics { * @private */ _recordInstall() { - // We don't want to record 'install' twice - if (this.storage.install_all) { - return; + if (this.isJustInstalled()) { + this._sendReq('install'); } - this._sendReq('install'); } /** diff --git a/src/store/options.js b/src/store/options.js index 1c1784d70..c21d59d49 100644 --- a/src/store/options.js +++ b/src/store/options.js @@ -15,6 +15,7 @@ import { deleteDB } from 'idb'; import { getUserOptions, setUserOptions } from '/utils/api.js'; import { DEFAULT_REGIONS } from '/utils/regions.js'; import { isOpera } from '/utils/browser-info.js'; +import * as OptionsObserver from '/utils/options-observer.js'; import Session from './session.js'; import CustomFilters from './custom-filters.js'; @@ -22,12 +23,6 @@ import CustomFilters from './custom-filters.js'; const UPDATE_OPTIONS_ACTION_NAME = 'updateOptions'; export const GLOBAL_PAUSE_ID = ''; -const observers = new Set(); - -// The promise is resolved when all observers executed. -// This is used by the e2e tests to detect idle state. -export let idleOptionsObservers = Promise.resolve(); - export const SYNC_OPTIONS = [ 'blockAds', 'blockTrackers', @@ -174,27 +169,21 @@ const Options = { // sendMessage may fail without potential target }); - sync(options, keys).catch(() => null); + sync(options, keys); return options; }, observe: (_, options, prevOptions) => { + OptionsObserver.execute(options, prevOptions); + // Sync if the current memory context get options for the first time if (!prevOptions) sync(options); - - idleOptionsObservers = (async () => { - for (const fn of observers) { - try { - await fn(options, prevOptions); - } catch (e) { - console.error(`[options] Error while observing options: `, e); - } - } - })(); }, }, }; +export default Options; + chrome.runtime.onMessage.addListener((msg) => { if (msg.action === UPDATE_OPTIONS_ACTION_NAME) { store.clear(Options, false); @@ -202,7 +191,12 @@ chrome.runtime.onMessage.addListener((msg) => { } }); -export default Options; +export function isPaused(options, domain = '') { + return ( + !!options.paused[GLOBAL_PAUSE_ID] || + (domain && !!options.paused[domain.replace(/^www\./, '')]) + ); +} export async function sync(options, keys) { try { @@ -337,70 +331,3 @@ async function migrateFromV8() { return {}; } } - -function isOptionEqual(a, b) { - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - - return ( - aKeys.length === bKeys.length && - aKeys.every((key) => - typeof a[key] === 'object' - ? isOptionEqual(a[key], b[key]) - : a[key] === b[key], - ) - ); -} - -export async function observe(...args) { - let wrapper; - - if (args.length === 2) { - const [property, fn] = args; - let value; - - if (typeof Options[property] === 'object') { - wrapper = async (options) => { - if (value === undefined || !isOptionEqual(options[property], value)) { - const prevValue = value; - value = options[property]; - return await fn(value, prevValue); - } - }; - } else { - wrapper = async (options) => { - if (value === undefined || options[property] !== value) { - const prevValue = value; - value = options[property]; - return await fn(value, prevValue); - } - }; - } - } else { - wrapper = args[0]; - } - - try { - const options = await store.resolve(Options); - // let observer know of the option value - // in case when registered after the store.connect - // wait for the callback to be fired - await wrapper(options); - } catch (e) { - console.error(`[options] Error while observing options: `, e); - } - - observers.add(wrapper); - - // Return unobserve function - return () => { - observers.delete(wrapper); - }; -} - -export function isPaused(options, domain = '') { - return ( - !!options.paused[GLOBAL_PAUSE_ID] || - (domain && !!options.paused[domain.replace(/^www\./, '')]) - ); -} diff --git a/src/utils/errors.js b/src/utils/errors.js index 5be01b3ce..a7e6371c6 100644 --- a/src/utils/errors.js +++ b/src/utils/errors.js @@ -11,7 +11,8 @@ import * as Sentry from '@sentry/browser'; -import { observe } from '/store/options.js'; +import { store } from 'hybrids'; +import Options from '/store/options.js'; import getBrowserInfo from './browser-info.js'; import debug, { debugMode } from './debug.js'; @@ -44,21 +45,20 @@ getBrowserInfo().then( () => {}, ); -let terms = false; -observe('terms', (value) => { - terms = value; -}); +export async function captureException(error) { + const { terms } = await store.resolve(Options); -export function captureException(error) { - if (!terms || !(error instanceof Error)) return; + if (__PLATFORM__ === 'tests' || !terms || !(error instanceof Error)) { + return; + } const newError = new Error(error.message); + newError.name = error.name; newError.cause = error.cause; newError.stack = error.stack.replace(hostRegexp, 'filtered'); - if (__PLATFORM__ !== 'tests') { - Sentry.captureException(newError); - } + + Sentry.captureException(newError); } debug.errors = { captureException }; diff --git a/src/utils/options-observer.js b/src/utils/options-observer.js new file mode 100644 index 000000000..b920f7067 --- /dev/null +++ b/src/utils/options-observer.js @@ -0,0 +1,93 @@ +/** + * Ghostery Browser Extension + * https://www.ghostery.com/ + * + * Copyright 2017-present Ghostery GmbH. All rights reserved. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0 + */ +import { store } from 'hybrids'; +import Options from '/store/options.js'; + +function isOptionEqual(a, b) { + if (typeof b !== 'object' || b === null) return a === b; + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + return ( + aKeys.length === bKeys.length && + aKeys.every((key) => isOptionEqual(a[key], b[key])) + ); +} + +const observers = new Set(); +let setup = null; + +export function addListener(...args) { + if (setup === 'done') { + throw new Error('The observer must be initialized synchronously'); + } + + if (setup === null) { + // Run observers after all callbacks are registered + setup = store.resolve(Options).then(() => { + setup = 'done'; + }); + } + + return new Promise((resolve, reject) => { + const fn = args[1] || args[0]; + const property = args.length === 2 ? args[0] : null; + + const getValue = property ? (v) => v[property] : (v) => v; + const getPrevValue = property ? (v) => v?.[property] : (v) => v; + + const wrapper = async (options, prevOptions) => { + const value = getValue(options); + const prevValue = getPrevValue(prevOptions); + + if (isOptionEqual(value, prevValue)) return; + + try { + console.group(`[options] "${fn.name || property}" observer`); + await fn(value, prevValue); + console.groupEnd(); + resolve(); + } catch (e) { + reject(e); + throw e; + } + }; + + observers.add(wrapper); + }); +} + +let queues = new Set(); +export async function waitForIdle() { + for (const queue of queues) await queue; +} + +export async function execute(options, prevOptions) { + if (observers.size === 0) return; + + const queue = Promise.allSettled([...queues]).then(async () => { + console.debug(`[options] Run observers (start)`); + + for (const fn of observers) { + try { + await fn(options, prevOptions); + } catch (e) { + console.error(`Error while executing observer: `, e); + } + } + + console.debug(`[options] Run observers (end)`); + queues.delete(queue); + }); + + queues.add(queue); +} diff --git a/src/utils/setup.js b/src/utils/setup.js index cf8d8ac3b..90356f1a0 100644 --- a/src/utils/setup.js +++ b/src/utils/setup.js @@ -9,7 +9,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0 */ -export default function asyncSetup(promises, threshold = 5000) { +export default function asyncSetup(promises, threshold = 10000) { let timeoutId; const result = {