diff --git a/src/background/index.js b/src/background/index.js index 87fc23f25..5235b6b04 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -24,6 +24,7 @@ import './serp.js'; import './helpers.js'; import './external.js'; +import './sync.js'; import './reporting/index.js'; import './telemetry/index.js'; diff --git a/src/background/session.js b/src/background/session.js index c14f1e62e..919e40d8c 100644 --- a/src/background/session.js +++ b/src/background/session.js @@ -8,49 +8,12 @@ * 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, { sync } from '/store/options.js'; -import Session, { UPDATE_SESSION_ACTION_NAME } from '/store/session.js'; - +import { UPDATE_SESSION_ACTION_NAME } from '/store/session.js'; import { HOME_PAGE_URL, ACCOUNT_PAGE_URL } from '/utils/api.js'; -// Trigger options sync every one day -const ALARM_SYNC_OPTIONS = 'session:sync:options'; -const ALARM_SYNC_OPTIONS_RATE = 1 * 60 * 24; // 1 day in minutes - -// Try to check cookies every 30 days if the user is still logged in -const ALARM_UPDATE_SESSION = 'session:update'; -const ALARM_UPDATE_SESSION_DELAY = 1000 * 60 * 24 * 30; // 30 days in milliseconds - -async function syncOptions() { - sync(await store.resolve(Options)); -} - // Observe cookie changes (login/logout actions) chrome.webNavigation.onDOMContentLoaded.addListener(async ({ url = '' }) => { if (url === HOME_PAGE_URL || url.includes(ACCOUNT_PAGE_URL)) { - const { user } = await store.resolve(Session); - - if (user) { - if (!(await chrome.alarms.get(ALARM_SYNC_OPTIONS))) { - chrome.alarms.create(ALARM_SYNC_OPTIONS, { - periodInMinutes: ALARM_SYNC_OPTIONS_RATE, - }); - } - - if (!(await chrome.alarms.get(ALARM_UPDATE_SESSION))) { - chrome.alarms.create(ALARM_UPDATE_SESSION, { - when: Date.now() + ALARM_UPDATE_SESSION_DELAY, - }); - } - } else { - chrome.alarms.clear(ALARM_SYNC_OPTIONS); - chrome.alarms.clear(ALARM_UPDATE_SESSION); - } - - syncOptions(); - // Send message to update session in other contexts chrome.runtime .sendMessage({ action: UPDATE_SESSION_ACTION_NAME }) @@ -60,25 +23,3 @@ chrome.webNavigation.onDOMContentLoaded.addListener(async ({ url = '' }) => { .catch(() => null); } }); - -chrome.alarms.onAlarm.addListener(async ({ name }) => { - switch (name) { - case ALARM_SYNC_OPTIONS: - syncOptions(); - break; - case ALARM_UPDATE_SESSION: { - const { user } = await store.resolve(Session); - - if (!user) { - chrome.alarms.clear(ALARM_UPDATE_SESSION); - } else { - chrome.alarms.create(ALARM_UPDATE_SESSION, { - when: Date.now() + ALARM_UPDATE_SESSION_DELAY, - }); - } - break; - } - default: - break; - } -}); diff --git a/src/background/sync.js b/src/background/sync.js new file mode 100644 index 000000000..2e03bb195 --- /dev/null +++ b/src/background/sync.js @@ -0,0 +1,144 @@ +/** + * 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, { SYNC_OPTIONS } from '/store/options.js'; +import Session from '/store/session.js'; +import { getUserOptions, setUserOptions } from '/utils/api.js'; +import * as OptionsObserver from '/utils/options-observer.js'; +import { HOME_PAGE_URL, ACCOUNT_PAGE_URL } from '/utils/api.js'; + +async function sync(options, prevOptions) { + if (sync.pending) { + console.warn('[sync] Sync already in progress...'); + return; + } + + try { + sync.pending = true; + + // Do not sync if revision is set or terms and sync options are false + if (!options.terms || !options.sync) { + return; + } + + const { user } = await store.resolve(Session); + + // If user is not logged in, clean up options revision and return + if (!user) { + if (options.revision !== 0) { + store.set(Options, { revision: 0 }); + } + return; + } + + const keys = + prevOptions && + SYNC_OPTIONS.filter( + (key) => !OptionsObserver.isOptionEqual(options[key], prevOptions[key]), + ); + + // If options update, set revision to "dirty" state + if (keys && options.revision > 0) { + // Updated keys are not synchronized + if (keys.length === 0) return; + + options = await store.set(Options, { revision: options.revision * -1 }); + } + + const serverOptions = await getUserOptions(); + + // Server has newer options - merge with local options + // The try/catch block is used to prevent failure of updating local options + // with server options with obsolete structure + try { + if (serverOptions.revision > Math.abs(options.revision)) { + console.info( + '[sync] Merging server options with revision:', + serverOptions.revision, + ); + const values = SYNC_OPTIONS.reduce( + (acc, key) => { + if ( + !keys?.includes(key) && + hasOwnProperty.call(serverOptions, key) + ) { + acc[key] = serverOptions[key]; + } + + return acc; + }, + { revision: serverOptions.revision }, + ); + + options = await store.set(Options, values); + } + } catch (e) { + console.error(`[sync] Error while merging server options: `, e); + } + + // Set options or update: + // * No revision on server - initial sync + // * Keys are passed - options update + // * Revision is negative - local options are dirty (not synced) + if (!serverOptions.revision || keys || options.revision < 0) { + console.info('[sync] Syncing options with updated keys:', keys); + const { revision } = await setUserOptions( + SYNC_OPTIONS.reduce( + (acc, key) => { + if (hasOwnProperty.call(options, key)) { + acc[key] = options[key]; + } + return acc; + }, + { revision: serverOptions.revision + 1 }, + ), + ); + + // Update local revision + await store.set(Options, { revision }); + console.info('[sync] Options synced with revision:', revision); + } + } catch (e) { + console.error(`[sync] Error while syncing options: `, e); + } finally { + sync.pending = false; + } +} + +// Sync options on startup and when options change +OptionsObserver.addListener(function syncOptions(options, prevOptions) { + // Sync options on startup + if (!prevOptions) { + sync(options); + } + + // Sync options when options change (skip on revision change) + else if (options.revision === prevOptions.revision) { + sync(options, prevOptions); + } +}); + +// Sync options when a user logs in/out directly +// from the ghostery.com page (not from the settings page) +chrome.webNavigation.onDOMContentLoaded.addListener(async ({ url = '' }) => { + if (url === HOME_PAGE_URL || url.includes(ACCOUNT_PAGE_URL)) { + store.resolve(Options).then((options) => sync(options)); + } +}); + +// Sync options on demand - triggered by the options page and panel +// to force sync options when opened +chrome.runtime.onMessage.addListener((msg) => { + if (msg.action === 'syncOptions') { + store.resolve(Options).then((options) => sync(options)); + } +}); diff --git a/src/pages/panel/index.js b/src/pages/panel/index.js index 8036d5164..95b5048ed 100644 --- a/src/pages/panel/index.js +++ b/src/pages/panel/index.js @@ -25,13 +25,14 @@ mount(document.body, { `, }); -/* Ping telemetry on panel open */ +// Ping telemetry on panel open chrome.runtime.sendMessage({ action: 'telemetry', event: 'engaged' }); -/* - Safari extension popup has a bug, which focuses visibly the first element on the page - when the popup is opened. This is a workaround to remove the focus. -*/ +// Sync options with background +chrome.runtime.sendMessage({ action: 'syncOptions' }); + +// Safari extension popup has a bug, which focuses visibly the first element on the page +// when the popup is opened. This is a workaround to remove the focus. if (__PLATFORM__ === 'safari') { window.addEventListener('load', () => { setTimeout(() => { diff --git a/src/pages/settings/index.js b/src/pages/settings/index.js index d95f6e0e1..87d669f21 100644 --- a/src/pages/settings/index.js +++ b/src/pages/settings/index.js @@ -36,6 +36,9 @@ store }; } + // Sync options with background + chrome.runtime.sendMessage({ action: 'syncOptions' }); + mount(document.body, Settings); }) .catch(() => { diff --git a/src/pages/settings/views/account.js b/src/pages/settings/views/account.js index 422665e90..15619fef4 100644 --- a/src/pages/settings/views/account.js +++ b/src/pages/settings/views/account.js @@ -26,8 +26,8 @@ function openGhosteryPage(url) { details.tabId === tab.id && details.url.startsWith(ACCOUNT_PAGE_URL) ) { - chrome.tabs.remove(tab.id); chrome.webNavigation.onCommitted.removeListener(onSuccess); + chrome.tabs.remove(tab.id); } }; @@ -36,7 +36,12 @@ function openGhosteryPage(url) { chrome.webNavigation.onCommitted.removeListener(onSuccess); chrome.tabs.onRemoved.removeListener(onRemove); + // The tab is closed before the background listeners can catch the event + // so we need to refresh session and trigger sync manually store.clear(Session); + chrome.runtime.sendMessage({ action: 'syncOptions' }); + + // Restore the original tab chrome.tabs.update(currentTab[0].id, { active: true }); } }; @@ -120,34 +125,27 @@ export default { `)} - ${store.ready(session) && - html` -
+ - -
-
- - Settings Sync -
- - Saves and synchronizes your custom settings between browsers - and devices. - +
+
+ + Settings Sync
- -
- `} + + Saves and synchronizes your custom settings between browsers + and devices. + +
+
+
diff --git a/src/store/options.js b/src/store/options.js index 314893296..1c4688630 100644 --- a/src/store/options.js +++ b/src/store/options.js @@ -11,15 +11,13 @@ import { store } from 'hybrids'; -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 CustomFilters from './custom-filters.js'; -import Session from './session.js'; -const UPDATE_OPTIONS_ACTION_NAME = 'updateOptions'; +export const UPDATE_OPTIONS_ACTION_NAME = 'updateOptions'; export const GLOBAL_PAUSE_ID = ''; export const SYNC_OPTIONS = [ @@ -133,20 +131,16 @@ const Options = { chrome.runtime .sendMessage({ action: UPDATE_OPTIONS_ACTION_NAME, + keys, }) .catch(() => { // sendMessage may fail without potential target }); - 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); }, }, }; @@ -161,8 +155,7 @@ chrome.runtime.onMessage.addListener((msg) => { }); async function migrate(options, optionsVersion) { - const keys = []; - + // Pushed in v10.3.14 if (optionsVersion < 2) { // Migrate 'paused' array to record if (options.paused) { @@ -171,8 +164,11 @@ async function migrate(options, optionsVersion) { return acc; }, {}); } + + console.debug('[options] Migrated to version 2:', options); } + // Pushed in v10.4.3 if (optionsVersion < 3) { // Check if the user has custom filters, so we need to // reflect the enabled state in the options @@ -182,8 +178,9 @@ async function migrate(options, optionsVersion) { ...options.customFilters, enabled: true, }; - keys.push('customFilters'); } + + console.debug('[options] Migrated to version 3:', options); } // Flush updated options and version to the storage @@ -191,9 +188,6 @@ async function migrate(options, optionsVersion) { options, optionsVersion: OPTIONS_VERSION, }); - - // Send updated options to the server - Promise.resolve().then(() => sync(options, keys)); } let managed = __PLATFORM__ === 'chromium' && isOpera() ? false : null; @@ -244,68 +238,3 @@ export function isPaused(options, domain = '') { (domain && !!options.paused[domain.replace(/^www\./, '')]) ); } - -export async function sync(options, keys) { - try { - // Do not sync if revision is set or terms and sync options are false - if (keys?.includes('revision') || !options.terms || !options.sync) { - return; - } - - const { user } = await store.resolve(Session); - - // If user is not logged in, clean up options revision and return - if (!user) { - if (options.revision !== 0) { - store.set(Options, { revision: 0 }); - } - return; - } - - // If options update, set revision to "dirty" state - if (keys && options.revision > 0) { - options = await store.set(Options, { revision: options.revision * -1 }); - } - - const serverOptions = await getUserOptions(); - - // Server has newer options - merge with local options - if (serverOptions.revision > Math.abs(options.revision)) { - const values = SYNC_OPTIONS.reduce( - (acc, key) => { - if (!keys?.includes(key) && hasOwnProperty.call(serverOptions, key)) { - acc[key] = serverOptions[key]; - } - - return acc; - }, - { revision: serverOptions.revision }, - ); - - options = await store.set(Options, values); - } - - // Set options or update: - // * No revision on server - initial sync - // * Keys are passed - options update - // * Revision is negative - local options are dirty (not synced) - if (!serverOptions.revision || keys || options.revision < 0) { - const { revision } = await setUserOptions( - SYNC_OPTIONS.reduce( - (acc, key) => { - if (hasOwnProperty.call(options, key)) { - acc[key] = options[key]; - } - return acc; - }, - { revision: serverOptions.revision + 1 }, - ), - ); - - // Update local revision - await store.set(Options, { revision }); - } - } catch (e) { - console.error(`[options] Error while syncing options: `, e); - } -} diff --git a/src/utils/options-observer.js b/src/utils/options-observer.js index 393429f99..829a2c380 100644 --- a/src/utils/options-observer.js +++ b/src/utils/options-observer.js @@ -11,7 +11,7 @@ import { store } from 'hybrids'; import Options from '/store/options.js'; -function isOptionEqual(a, b) { +export function isOptionEqual(a, b) { if (typeof b !== 'object' || b === null) return a === b; const aKeys = Object.keys(a);