AboutNewTabComponents.sys.mjs (7642B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; 6 7 const CATEGORY_NAME = "browser-newtab-external-component"; 8 9 const lazy = {}; 10 11 ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { 12 return console.createInstance({ 13 prefix: "AboutNewTabComponents", 14 maxLogLevel: Services.prefs.getBoolPref( 15 "browser.newtabpage.activity-stream.externalComponents.log", 16 false 17 ) 18 ? "Debug" 19 : "Warn", 20 }); 21 }); 22 23 /** 24 * @typedef {object} NewTabComponentConfiguration 25 * @property {string} type 26 * @property {string[]} l10nURLs 27 * @property {string} componentURL 28 * @property {string} tagName 29 */ 30 31 /** 32 * The AboutNewTabComponentRegistry is a class that manages a list of 33 * external components registered to appear on the newtab page. 34 * 35 * The registry is an EventEmitter, and will emit the UPDATED_EVENT when the 36 * registry changes. 37 * 38 * The registry is bootstrapped via entries in the nsICategoryManager of name 39 * CATEGORY_NAME. 40 */ 41 class AboutNewTabComponentRegistry extends EventEmitter { 42 static TYPES = Object.freeze({ 43 SEARCH: "SEARCH", 44 }); 45 static UPDATED_EVENT = "updated"; 46 47 /** 48 * The list of registered external component configurations, keyed on their 49 * type. 50 * 51 * @type {Map<string, NewTabComponentConfiguration>} 52 */ 53 #registeredComponents = new Map(); 54 55 /** 56 * A mapping of external component registrant instances, keyed on their 57 * module URI. 58 * 59 * @type {Map<string, BaseAboutNewTabComponentRegistrant>} 60 */ 61 #registrants = new Map(); 62 63 constructor() { 64 super(); 65 66 lazy.logConsole.debug("Instantiating AboutNewTabComponentRegistry"); 67 this.#infalliblyLoadConfigurations(); 68 69 Services.obs.addObserver(this, "xpcom-category-entry-removed"); 70 Services.obs.addObserver(this, "xpcom-category-entry-added"); 71 Services.obs.addObserver(this, "xpcom-category-cleared"); 72 Services.obs.addObserver(this, "profile-before-change"); 73 } 74 75 observe(subject, topic, data) { 76 switch (topic) { 77 case "xpcom-category-entry-removed": 78 // Intentional fall-through 79 case "xpcom-category-entry-added": 80 // Intentional fall-through 81 case "xpcom-category-cleared": { 82 // Intentional fall-through 83 if (data === CATEGORY_NAME) { 84 this.#infalliblyLoadConfigurations(); 85 } 86 break; 87 } 88 case "profile-before-change": { 89 this.destroy(); 90 break; 91 } 92 } 93 } 94 95 destroy() { 96 for (let registrant of this.#registrants.values()) { 97 registrant.destroy(); 98 } 99 this.#registrants.clear(); 100 this.#registeredComponents.clear(); 101 102 Services.obs.removeObserver(this, "xpcom-category-entry-removed"); 103 Services.obs.removeObserver(this, "xpcom-category-entry-added"); 104 Services.obs.removeObserver(this, "xpcom-category-cleared"); 105 Services.obs.removeObserver(this, "profile-before-change"); 106 } 107 108 /** 109 * Iterates the CATEGORY_NAME nsICategoryManager category, and attempts to 110 * load each registrant's configuration, which updates the 111 * #registeredComponents. Updating the #registeredComponents will cause the 112 * UPDATED_EVENT event to be emitted from this class. 113 * 114 * Invalid configurations are skipped. 115 * 116 * This method will log errors but is guaranteed to return, even if one or 117 * more of the configurations is invalid. 118 */ 119 #infalliblyLoadConfigurations() { 120 lazy.logConsole.debug("Loading configurations"); 121 this.#registeredComponents.clear(); 122 123 for (let { entry, value } of Services.catMan.enumerateCategory( 124 CATEGORY_NAME 125 )) { 126 try { 127 lazy.logConsole.debug("Loading ", entry, value); 128 let registrar = null; 129 if (this.#registrants.has(entry)) { 130 lazy.logConsole.debug("Found pre-existing registrant for ", entry); 131 registrar = this.#registrants.get(entry); 132 } else { 133 lazy.logConsole.debug("Constructing registrant for ", entry); 134 const module = ChromeUtils.importESModule(entry); 135 const registrarClass = module[value]; 136 137 if ( 138 !( 139 registrarClass.prototype instanceof 140 BaseAboutNewTabComponentRegistrant 141 ) 142 ) { 143 throw new Error( 144 `Registrant for ${entry} does not subclass BaseAboutNewTabComponentRegistrant` 145 ); 146 } 147 148 registrar = new registrarClass(); 149 this.#registrants.set(entry, registrar); 150 registrar.on(AboutNewTabComponentRegistry.UPDATED_EVENT, () => { 151 this.#infalliblyLoadConfigurations(); 152 }); 153 } 154 155 let configurations = registrar.getComponents(); 156 for (let configuration of configurations) { 157 if (this.#validateConfiguration(configuration)) { 158 lazy.logConsole.debug( 159 `Validated a configuration for type ${configuration.type}` 160 ); 161 this.#registeredComponents.set(configuration.type, configuration); 162 } else { 163 lazy.logConsole.error( 164 `Failed to validate a configuration:`, 165 configuration 166 ); 167 } 168 } 169 } catch (e) { 170 lazy.logConsole.error( 171 "Failed to load configurations ", 172 entry, 173 value, 174 e.message 175 ); 176 } 177 } 178 179 this.emit(AboutNewTabComponentRegistry.UPDATED_EVENT); 180 } 181 182 /** 183 * Ensures that the configuration abides by newtab's external component 184 * rules. Currently, that just means that two components cannot share the 185 * same type. 186 * 187 * @param {NewTabComponentConfiguration} configuration 188 * @returns {boolean} 189 */ 190 #validateConfiguration(configuration) { 191 if (!configuration.type) { 192 return false; 193 } 194 195 // Currently, the only validation is to ensure that something isn't already 196 // registered with the same type. This rule might evolve over time if we 197 // start allowing multiples of a type. 198 if (this.#registeredComponents.has(configuration.type)) { 199 return false; 200 } 201 202 return true; 203 } 204 205 /** 206 * Returns a copy of the configuration registry for external consumption. 207 * 208 * @returns {NewTabComponentConfiguration[]} 209 */ 210 get values() { 211 return Array.from(this.#registeredComponents.values()); 212 } 213 } 214 215 /** 216 * Any registrants that want to register an external component onto newtab must 217 * subclass this base class in order to provide the configuration for their 218 * component. They must then add their registrant to the nsICategoryManager 219 * category `browser-newtab-external-component`, where the entry is the URI 220 * for the module containing the subclass, and the value is the name of the 221 * subclass exported by the module. 222 */ 223 class BaseAboutNewTabComponentRegistrant extends EventEmitter { 224 /** 225 * Subclasses can override this method to do any cleanup when the component 226 * registry starts being shut down. 227 */ 228 destroy() {} 229 230 /** 231 * Subclasses should override this method to provide one or more 232 * NewTabComponentConfiguration's. 233 * 234 * @returns {NewTabComponentConfiguration[]} 235 */ 236 getComponents() { 237 return []; 238 } 239 240 /** 241 * Subclasses can call this method if their component registry ever needs 242 * updating. This will alert the registry to update itself. 243 */ 244 updated() { 245 this.emit(AboutNewTabComponentRegistry.UPDATED_EVENT); 246 } 247 } 248 249 export { AboutNewTabComponentRegistry, BaseAboutNewTabComponentRegistrant };