diff options
Diffstat (limited to '')
4 files changed, 715 insertions, 0 deletions
diff --git a/toolkit/components/messaging-system/lib/Logger.jsm b/toolkit/components/messaging-system/lib/Logger.jsm new file mode 100644 index 0000000000..2afc3aa526 --- /dev/null +++ b/toolkit/components/messaging-system/lib/Logger.jsm @@ -0,0 +1,22 @@ +/* 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/. */ +"use strict"; + +const EXPORTED_SYMBOLS = ["Logger"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm"); + +const LOGGING_PREF = "messaging-system.log"; + +class Logger extends ConsoleAPI { + constructor(name) { + let consoleOptions = { + prefix: name, + maxLogLevel: Services.prefs.getCharPref(LOGGING_PREF, "warn"), + maxLogLevelPref: LOGGING_PREF, + }; + super(consoleOptions); + } +} diff --git a/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm b/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm new file mode 100644 index 0000000000..8a6ff55911 --- /dev/null +++ b/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm @@ -0,0 +1,229 @@ +/* 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/. */ + +"use strict"; + +/** + * @typedef {import("../experiments/@types/ExperimentManager").Recipe} Recipe + */ + +const EXPORTED_SYMBOLS = [ + "_RemoteSettingsExperimentLoader", + "RemoteSettingsExperimentLoader", +]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm", + TargetingContext: "resource://messaging-system/targeting/Targeting.jsm", + ExperimentManager: + "resource://messaging-system/experiments/ExperimentManager.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", + CleanupManager: "resource://normandy/lib/CleanupManager.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "log", () => { + const { Logger } = ChromeUtils.import( + "resource://messaging-system/lib/Logger.jsm" + ); + return new Logger("RSLoader"); +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "timerManager", + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager" +); + +const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id"; +const COLLECTION_ID_FALLBACK = "nimbus-desktop-experiments"; +const ENABLED_PREF = "messaging-system.rsexperimentloader.enabled"; +const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled"; + +const TIMER_NAME = "rs-experiment-loader-timer"; +const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`; +// Use the same update interval as normandy +const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds"; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "COLLECTION_ID", + COLLECTION_ID_PREF, + COLLECTION_ID_FALLBACK +); + +class _RemoteSettingsExperimentLoader { + constructor() { + // Has the timer been set? + this._initialized = false; + // Are we in the middle of updating recipes already? + this._updating = false; + + // Make it possible to override for testing + this.manager = ExperimentManager; + + XPCOMUtils.defineLazyGetter(this, "remoteSettingsClient", () => { + return RemoteSettings(COLLECTION_ID); + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "enabled", + ENABLED_PREF, + false, + this.onEnabledPrefChange.bind(this) + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "studiesEnabled", + STUDIES_OPT_OUT_PREF, + false, + this.onEnabledPrefChange.bind(this) + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "intervalInSeconds", + RUN_INTERVAL_PREF, + 21600, + () => this.setTimer() + ); + } + + async init() { + if (this._initialized || !this.enabled || !this.studiesEnabled) { + return; + } + + this.setTimer(); + CleanupManager.addCleanupHandler(() => this.uninit()); + this._initialized = true; + + await this.updateRecipes(); + } + + uninit() { + if (!this._initialized) { + return; + } + timerManager.unregisterTimer(TIMER_NAME); + this._initialized = false; + } + + /** + * Checks targeting of a recipe if it is defined + * @param {Recipe} recipe + * @param {{[key: string]: any}} customContext A custom filter context + * @returns {Promise<boolean>} Should we process the recipe? + */ + async checkTargeting(recipe, customContext = {}) { + const context = TargetingContext.combineContexts( + { experiment: recipe }, + customContext, + ASRouterTargeting.Environment + ); + const { targeting } = recipe; + if (!targeting) { + log.debug("No targeting for recipe, so it matches automatically"); + return true; + } + log.debug("Testing targeting expression:", targeting); + const targetingContext = new TargetingContext(context); + let result = false; + try { + result = await targetingContext.evalWithDefault(targeting); + } catch (e) { + log.debug("Targeting failed because of an error"); + Cu.reportError(e); + } + return Boolean(result); + } + + /** + * Get all recipes from remote settings + * @param {string} trigger What caused the update to occur? + */ + async updateRecipes(trigger) { + if (this._updating || !this._initialized) { + return; + } + this._updating = true; + + log.debug("Updating recipes" + (trigger ? ` with trigger ${trigger}` : "")); + + let recipes; + let loadingError = false; + + try { + recipes = await this.remoteSettingsClient.get(); + log.debug(`Got ${recipes.length} recipes from Remote Settings`); + } catch (e) { + log.debug("Error getting recipes from remote settings."); + loadingError = true; + Cu.reportError(e); + } + + let matches = 0; + if (recipes && !loadingError) { + const context = this.manager.createTargetingContext(); + + for (const r of recipes) { + if (await this.checkTargeting(r, context)) { + matches++; + log.debug(`${r.id} matched`); + await this.manager.onRecipe(r, "rs-loader"); + } else { + log.debug(`${r.id} did not match due to targeting`); + } + } + + log.debug(`${matches} recipes matched. Finalizing ExperimentManager.`); + this.manager.onFinalize("rs-loader"); + } + + if (trigger !== "timer") { + const lastUpdateTime = Math.round(Date.now() / 1000); + Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime); + } + + this._updating = false; + } + + /** + * Handles feature status based on feature pref and STUDIES_OPT_OUT_PREF. + * Changing any of them to false will turn off any recipe fetching and + * processing. + */ + onEnabledPrefChange(prefName, oldValue, newValue) { + if (this._initialized && !newValue) { + this.uninit(); + } else if (!this._initialized && newValue && this.enabled) { + // If the feature pref is turned on then turn on recipe processing. + // If the opt in pref is turned on then turn on recipe processing only if + // the feature pref is also enabled. + this.init(); + } + } + + /** + * Sets a timer to update recipes every this.intervalInSeconds + */ + setTimer() { + // When this function is called, updateRecipes is also called immediately + timerManager.registerTimer( + TIMER_NAME, + () => this.updateRecipes("timer"), + this.intervalInSeconds + ); + log.debug("Registered update timer"); + } +} + +const RemoteSettingsExperimentLoader = new _RemoteSettingsExperimentLoader(); diff --git a/toolkit/components/messaging-system/lib/SharedDataMap.jsm b/toolkit/components/messaging-system/lib/SharedDataMap.jsm new file mode 100644 index 0000000000..b61b112a1d --- /dev/null +++ b/toolkit/components/messaging-system/lib/SharedDataMap.jsm @@ -0,0 +1,163 @@ +/* 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/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["SharedDataMap"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { EventEmitter } = ChromeUtils.import( + "resource://gre/modules/EventEmitter.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PromiseUtils", + "resource://gre/modules/PromiseUtils.jsm" +); + +const IS_MAIN_PROCESS = + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; + +ChromeUtils.defineModuleGetter( + this, + "JSONFile", + "resource://gre/modules/JSONFile.jsm" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +class SharedDataMap extends EventEmitter { + constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) { + super(); + + this._sharedDataKey = sharedDataKey; + this._isParent = options.isParent; + this._isReady = false; + this._readyDeferred = PromiseUtils.defer(); + this._data = null; + + if (this.isParent) { + // Lazy-load JSON file that backs Storage instances. + XPCOMUtils.defineLazyGetter(this, "_store", () => { + let path = options.path; + let store = null; + if (!path) { + try { + const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path; + path = PathUtils.join(profileDir, `${sharedDataKey}.json`); + } catch (e) { + Cu.reportError(e); + } + } + try { + store = new JSONFile({ path }); + } catch (e) { + Cu.reportError(e); + } + return store; + }); + } else { + this._syncFromParent(); + Services.cpmm.sharedData.addEventListener("change", this); + } + } + + async init() { + if (!this._isReady && this.isParent) { + await this._store.load(); + this._data = this._store.data; + this._syncToChildren({ flush: true }); + this._checkIfReady(); + } + } + + get sharedDataKey() { + return this._sharedDataKey; + } + + get isParent() { + return this._isParent; + } + + ready() { + return this._readyDeferred.promise; + } + + get(key) { + if (!this._data) { + return null; + } + return this._data[key]; + } + + set(key, value) { + if (!this.isParent) { + throw new Error( + "Setting values from within a content process is not allowed" + ); + } + this._store.data[key] = value; + this._store.saveSoon(); + this._syncToChildren(); + this._notifyUpdate(); + } + + // Only used in tests + _deleteForTests(key) { + if (!this.isParent) { + throw new Error( + "Setting values from within a content process is not allowed" + ); + } + if (this.has(key)) { + delete this._store.data[key]; + this._store.saveSoon(); + this._syncToChildren(); + this._notifyUpdate(); + } + } + + has(key) { + return Boolean(this.get(key)); + } + + /** + * Notify store listeners of updates + * Called both from Main and Content process + */ + _notifyUpdate(process = "parent") { + for (let key of Object.keys(this._data || {})) { + this.emit(`${process}-store-update:${key}`, this._data[key]); + } + } + + _syncToChildren({ flush = false } = {}) { + Services.ppmm.sharedData.set(this.sharedDataKey, this._data); + if (flush) { + Services.ppmm.sharedData.flush(); + } + } + + _syncFromParent() { + this._data = Services.cpmm.sharedData.get(this.sharedDataKey); + this._checkIfReady(); + this._notifyUpdate("child"); + } + + _checkIfReady() { + if (!this._isReady && this._data) { + this._isReady = true; + this._readyDeferred.resolve(); + } + } + + handleEvent(event) { + if (event.type === "change") { + if (event.changedKeys.includes(this.sharedDataKey)) { + this._syncFromParent(); + } + } + } +} diff --git a/toolkit/components/messaging-system/lib/SpecialMessageActions.jsm b/toolkit/components/messaging-system/lib/SpecialMessageActions.jsm new file mode 100644 index 0000000000..3d7bed5330 --- /dev/null +++ b/toolkit/components/messaging-system/lib/SpecialMessageActions.jsm @@ -0,0 +1,301 @@ +/* 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/. */ +"use strict"; + +const EXPORTED_SYMBOLS = ["SpecialMessageActions"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const DOH_DOORHANGER_DECISION_PREF = "doh-rollout.doorhanger-decision"; +const NETWORK_TRR_MODE_PREF = "network.trr.mode"; + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + UITour: "resource:///modules/UITour.jsm", + FxAccounts: "resource://gre/modules/FxAccounts.jsm", + MigrationUtils: "resource:///modules/MigrationUtils.jsm", +}); + +const SpecialMessageActions = { + // This is overridden by ASRouter.init + blockMessageById() { + throw new Error("ASRouter not intialized yet"); + }, + + /** + * loadAddonIconInURLBar - load addons-notification icon by displaying + * box containing addons icon in urlbar. See Bug 1513882 + * + * @param {Browser} browser browser element for showing addons icon + */ + loadAddonIconInURLBar(browser) { + if (!browser) { + return; + } + const chromeDoc = browser.ownerDocument; + let notificationPopupBox = chromeDoc.getElementById( + "notification-popup-box" + ); + if (!notificationPopupBox) { + return; + } + if ( + notificationPopupBox.style.display === "none" || + notificationPopupBox.style.display === "" + ) { + notificationPopupBox.style.display = "block"; + } + }, + + /** + * + * @param {Browser} browser The revelant Browser + * @param {string} url URL to look up install location + * @param {string} telemetrySource Telemetry information to pass to getInstallForURL + */ + async installAddonFromURL(browser, url, telemetrySource = "amo") { + try { + this.loadAddonIconInURLBar(browser); + const aUri = Services.io.newURI(url); + const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + + // AddonManager installation source associated to the addons installed from activitystream's CFR + // and RTAMO (source is going to be "amo" if not configured explicitly in the message provider). + const telemetryInfo = { source: telemetrySource }; + const install = await AddonManager.getInstallForURL(aUri.spec, { + telemetryInfo, + }); + await AddonManager.installAddonFromWebpage( + "application/x-xpinstall", + browser, + systemPrincipal, + install + ); + } catch (e) { + Cu.reportError(e); + } + }, + + /** + * Set browser as the operating system default browser. + * + * @param {Window} window Reference to a window object + */ + setDefaultBrowser(window) { + window.getShellService().setAsDefault(); + }, + + /** + * Reset browser homepage and newtab to default with a certain section configuration + * + * @param {"default"|null} home Value to set for browser homepage + * @param {"default"|null} newtab Value to set for browser newtab + * @param {obj} layout Configuration options for newtab sections + * @returns {undefined} + */ + configureHomepage({ homePage = null, newtab = null, layout = null }) { + // Homepage can be default, blank or a custom url + if (homePage === "default") { + Services.prefs.clearUserPref("browser.startup.homepage"); + } + // Newtab page can only be default or blank + if (newtab === "default") { + Services.prefs.clearUserPref("browser.newtabpage.enabled"); + } + if (layout) { + // Existing prefs that interact with the newtab page layout, we default to true + // or payload configuration + let newtabConfigurations = [ + [ + // controls the search bar + "browser.newtabpage.activity-stream.showSearch", + layout.search, + ], + [ + // controls the topsites + "browser.newtabpage.activity-stream.feeds.topsites", + layout.topsites, + // User can control number of topsite rows + ["browser.newtabpage.activity-stream.topSitesRows"], + ], + [ + // controls the highlights section + "browser.newtabpage.activity-stream.feeds.section.highlights", + layout.highlights, + // User can control number of rows and highlight sources + [ + "browser.newtabpage.activity-stream.section.highlights.rows", + "browser.newtabpage.activity-stream.section.highlights.includeVisited", + "browser.newtabpage.activity-stream.section.highlights.includePocket", + "browser.newtabpage.activity-stream.section.highlights.includeDownloads", + "browser.newtabpage.activity-stream.section.highlights.includeBookmarks", + ], + ], + [ + // controls the snippets section + "browser.newtabpage.activity-stream.feeds.snippets", + layout.snippets, + ], + [ + // controls the topstories section + "browser.newtabpage.activity-stream.feeds.system.topstories", + layout.topstories, + ], + ].filter( + // If a section has configs that the user changed we will skip that section + ([, , sectionConfigs]) => + !sectionConfigs || + sectionConfigs.every( + prefName => !Services.prefs.prefHasUserValue(prefName) + ) + ); + + for (let [prefName, prefValue] of newtabConfigurations) { + Services.prefs.setBoolPref(prefName, prefValue); + } + } + }, + + /** + * Processes "Special Message Actions", which are definitions of behaviors such as opening tabs + * installing add-ons, or focusing the awesome bar that are allowed to can be triggered from + * Messaging System interactions. + * + * @param {{type: string, data?: any}} action User action defined in message JSON. + * @param browser {Browser} The browser most relvant to the message. + */ + async handleAction(action, browser) { + const window = browser.ownerGlobal; + switch (action.type) { + case "SHOW_MIGRATION_WIZARD": + MigrationUtils.showMigrationWizard(window, [ + MigrationUtils.MIGRATION_ENTRYPOINT_NEWTAB, + action.data?.source, + ]); + break; + case "OPEN_PRIVATE_BROWSER_WINDOW": + // Forcefully open about:privatebrowsing + window.OpenBrowserWindow({ private: true }); + break; + case "OPEN_URL": + window.openLinkIn( + Services.urlFormatter.formatURL(action.data.args), + action.data.where || "current", + { + private: false, + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + csp: null, + } + ); + break; + case "OPEN_ABOUT_PAGE": + let aboutPageURL = new URL(`about:${action.data.args}`); + if (action.data.entrypoint) { + aboutPageURL.search = action.data.entrypoint; + } + window.openTrustedLinkIn( + aboutPageURL.toString(), + action.data.where || "tab" + ); + break; + case "OPEN_PREFERENCES_PAGE": + window.openPreferences( + action.data.category || action.data.args, + action.data.entrypoint && { + urlParams: { entrypoint: action.data.entrypoint }, + } + ); + break; + case "OPEN_APPLICATIONS_MENU": + UITour.showMenu(window, action.data.args); + break; + case "HIGHLIGHT_FEATURE": + const highlight = await UITour.getTarget(window, action.data.args); + if (highlight) { + await UITour.showHighlight(window, highlight, "none", { + autohide: true, + }); + } + break; + case "INSTALL_ADDON_FROM_URL": + await this.installAddonFromURL( + browser, + action.data.url, + action.data.telemetrySource + ); + break; + case "SET_DEFAULT_BROWSER": + this.setDefaultBrowser(window); + break; + case "PIN_CURRENT_TAB": + let tab = window.gBrowser.selectedTab; + window.gBrowser.pinTab(tab); + window.ConfirmationHint.show(tab, "pinTab", { + showDescription: true, + }); + break; + case "SHOW_FIREFOX_ACCOUNTS": + const data = action.data; + const url = await FxAccounts.config.promiseConnectAccountURI( + (data && data.entrypoint) || "snippets", + (data && data.extraParams) || {} + ); + // We want to replace the current tab. + window.openLinkIn(url, "current", { + private: false, + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + csp: null, + }); + break; + case "OPEN_PROTECTION_PANEL": + let { gProtectionsHandler } = window; + gProtectionsHandler.showProtectionsPopup({}); + break; + case "OPEN_PROTECTION_REPORT": + window.gProtectionsHandler.openProtections(); + break; + case "OPEN_AWESOME_BAR": + window.gURLBar.search(""); + break; + case "DISABLE_STP_DOORHANGERS": + await this.blockMessageById([ + "SOCIAL_TRACKING_PROTECTION", + "FINGERPRINTERS_PROTECTION", + "CRYPTOMINERS_PROTECTION", + ]); + break; + case "DISABLE_DOH": + Services.prefs.setStringPref( + DOH_DOORHANGER_DECISION_PREF, + "UIDisabled" + ); + Services.prefs.setIntPref(NETWORK_TRR_MODE_PREF, 5); + break; + case "ACCEPT_DOH": + Services.prefs.setStringPref(DOH_DOORHANGER_DECISION_PREF, "UIOk"); + break; + case "CANCEL": + // A no-op used by CFRs that minimizes the notification but does not + // trigger a dismiss or block (it keeps the notification around) + break; + case "CONFIGURE_HOMEPAGE": + this.configureHomepage(action.data); + const topWindow = browser.ownerGlobal.window.BrowserWindowTracker.getTopWindow(); + if (topWindow) { + topWindow.BrowserHome(); + } + break; + default: + throw new Error( + `Special message action with type ${action.type} is unsupported.` + ); + } + }, +}; |