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/*
6 * Implements low-overhead integration between components of the application.
7 * This may have different uses depending on the component, including:
8 *
9 * - Providing product-specific implementations registered at startup.
10 * - Using alternative implementations during unit tests.
11 * - Allowing add-ons to change specific behaviors.
12 *
13 * Components may define one or more integration points, each defined by a
14 * root integration object whose properties and methods are the public interface
15 * and default implementation of the integration point. For example:
16 *
17 *   const DownloadIntegration = {
18 *     getTemporaryDirectory() {
19 *       return "/tmp/";
20 *     },
21 *
22 *     getTemporaryFile(name) {
23 *       return this.getTemporaryDirectory() + name;
24 *     },
25 *   };
26 *
27 * Other parts of the application may register overrides for some or all of the
28 * defined properties and methods. The component defining the integration point
29 * does not have to be loaded at this stage, because the name of the integration
30 * point is the only information required. For example, if the integration point
31 * is called "downloads":
32 *
33 *   Integration.downloads.register(base => ({
34 *     getTemporaryDirectory() {
35 *       return base.getTemporaryDirectory.call(this) + "subdir/";
36 *     },
37 *   }));
38 *
39 * When the component defining the integration point needs to call a method on
40 * the integration object, instead of using it directly the component would use
41 * the "getCombined" method to retrieve an object that includes all overrides.
42 * For example:
43 *
44 *   let combined = Integration.downloads.getCombined(DownloadIntegration);
45 *   Assert.is(combined.getTemporaryFile("file"), "/tmp/subdir/file");
46 *
47 * Overrides can be registered at startup or at any later time, so each call to
48 * "getCombined" may return a different object. The simplest way to create a
49 * reference to the combined object that stays updated to the latest version is
50 * to define the root object in a JSM and use the "defineModuleGetter" method.
51 *
52 * *** Registration ***
53 *
54 * Since the interface is not declared formally, the registrations can happen
55 * at startup without loading the component, so they do not affect performance.
56 *
57 * Hovever, this module does not provide a startup registry, this means that the
58 * code that registers and implements the override must be loaded at startup.
59 *
60 * If performance for the override code is a concern, you can take advantage of
61 * the fact that the function used to create the override is called lazily, and
62 * include only a stub loader for the final code in an existing startup module.
63 *
64 * The registration of overrides should be repeated for each process where the
65 * relevant integration methods will be called.
66 *
67 * *** Accessing base methods and properties ***
68 *
69 * Overrides are included in the prototype chain of the combined object in the
70 * same order they were registered, where the first is closest to the root.
71 *
72 * When defining overrides, you do not need to set the "__proto__" property of
73 * the objects you create, because their properties and methods are moved to a
74 * new object with the correct prototype. If you do, however, you can call base
75 * properties and methods using the "super" keyword. For example:
76 *
77 *   Integration.downloads.register(base => ({
78 *     __proto__: base,
79 *     getTemporaryDirectory() {
80 *       return super.getTemporaryDirectory() + "subdir/";
81 *     },
82 *   }));
83 *
84 * *** State handling ***
85 *
86 * Storing state directly on the combined integration object using the "this"
87 * reference is not recommended. When a new integration is registered, own
88 * properties stored on the old combined object are copied to the new combined
89 * object using a shallow copy, but the "this" reference for new invocations
90 * of the methods will be different.
91 *
92 * If the root object defines a property that always points to the same object,
93 * for example a "state" property, you can safely use it across registrations.
94 *
95 * Integration overrides provided by restartless add-ons should not use the
96 * "this" reference to store state, to avoid conflicts with other add-ons.
97 *
98 * *** Interaction with XPCOM ***
99 *
100 * Providing the combined object as an argument to any XPCOM method will
101 * generate a console error message, and will throw an exception where possible.
102 * For example, you cannot register observers directly on the combined object.
103 * This helps preventing mistakes due to the fact that the combined object
104 * reference changes when new integration overrides are registered.
105 */
106
107"use strict";
108
109this.EXPORTED_SYMBOLS = [
110  "Integration",
111];
112
113const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
114
115Cu.import("resource://gre/modules/XPCOMUtils.jsm");
116
117/**
118 * Maps integration point names to IntegrationPoint objects.
119 */
120const gIntegrationPoints = new Map();
121
122/**
123 * This Proxy object creates IntegrationPoint objects using their name as key.
124 * The objects will be the same for the duration of the process. For example:
125 *
126 *   Integration.downloads.register(...);
127 *   Integration["addon-provided-integration"].register(...);
128 */
129this.Integration = new Proxy({}, {
130  get(target, name) {
131    let integrationPoint = gIntegrationPoints.get(name);
132    if (!integrationPoint) {
133      integrationPoint = new IntegrationPoint();
134      gIntegrationPoints.set(name, integrationPoint);
135    }
136    return integrationPoint;
137  },
138});
139
140/**
141 * Individual integration point for which overrides can be registered.
142 */
143this.IntegrationPoint = function () {
144  this._overrideFns = new Set();
145  this._combined = {
146    QueryInterface: function() {
147      let ex = new Components.Exception(
148                   "Integration objects should not be used with XPCOM because" +
149                   " they change when new overrides are registered.",
150                   Cr.NS_ERROR_NO_INTERFACE);
151      Cu.reportError(ex);
152      throw ex;
153    },
154  };
155}
156
157this.IntegrationPoint.prototype = {
158  /**
159   * Ordered set of registered functions defining integration overrides.
160   */
161  _overrideFns: null,
162
163  /**
164   * Combined integration object. When this reference changes, properties
165   * defined directly on this object are copied to the new object.
166   *
167   * Initially, the only property of this object is a "QueryInterface" method
168   * that throws an exception, to prevent misuse as a permanent XPCOM listener.
169   */
170  _combined: null,
171
172  /**
173   * Indicates whether the integration object is current based on the list of
174   * registered integration overrides.
175   */
176  _combinedIsCurrent: false,
177
178  /**
179   * Registers new overrides for the integration methods. For example:
180   *
181   *   Integration.nameOfIntegrationPoint.register(base => ({
182   *     asyncMethod: Task.async(function* () {
183   *       return yield base.asyncMethod.apply(this, arguments);
184   *     }),
185   *   }));
186   *
187   * @param overrideFn
188   *        Function returning an object defining the methods that should be
189   *        overridden. Its only parameter is an object that contains the base
190   *        implementation of all the available methods.
191   *
192   * @note The override function is called every time the list of registered
193   *       override functions changes. Thus, it should not have any side
194   *       effects or do any other initialization.
195   */
196  register(overrideFn) {
197    this._overrideFns.add(overrideFn);
198    this._combinedIsCurrent = false;
199  },
200
201  /**
202   * Removes a previously registered integration override.
203   *
204   * Overrides don't usually need to be unregistered, unless they are added by a
205   * restartless add-on, in which case they should be unregistered when the
206   * add-on is disabled or uninstalled.
207   *
208   * @param overrideFn
209   *        This must be the same function object passed to "register".
210   */
211  unregister(overrideFn) {
212    this._overrideFns.delete(overrideFn);
213    this._combinedIsCurrent = false;
214  },
215
216  /**
217   * Retrieves the dynamically generated object implementing the integration
218   * methods. Platform-specific code and add-ons can override methods of this
219   * object using the "register" method.
220   */
221  getCombined(root) {
222    if (this._combinedIsCurrent) {
223      return this._combined;
224    }
225
226    // In addition to enumerating all the registered integration overrides in
227    // order, we want to keep any state that was previously stored in the
228    // combined object using the "this" reference in integration methods.
229    let overrideFnArray = [...this._overrideFns, () => this._combined];
230
231    let combined = root;
232    for (let overrideFn of overrideFnArray) {
233      try {
234        // Obtain a new set of methods from the next override function in the
235        // list, specifying the current combined object as the base argument.
236        let override = overrideFn.call(null, combined);
237
238        // Retrieve a list of property descriptors from the returned object, and
239        // use them to build a new combined object whose prototype points to the
240        // previous combined object.
241        let descriptors = {};
242        for (let name of Object.getOwnPropertyNames(override)) {
243          descriptors[name] = Object.getOwnPropertyDescriptor(override, name);
244        }
245        combined = Object.create(combined, descriptors);
246      } catch (ex) {
247        // Any error will result in the current override being skipped.
248        Cu.reportError(ex);
249      }
250    }
251
252    this._combinedIsCurrent = true;
253    return this._combined = combined;
254  },
255
256  /**
257   * Defines a getter to retrieve the dynamically generated object implementing
258   * the integration methods, loading the root implementation lazily from the
259   * specified JSM module. For example:
260   *
261   *   Integration.test.defineModuleGetter(this, "TestIntegration",
262   *                    "resource://testing-common/TestIntegration.jsm");
263   *
264   * @param targetObject
265   *        The object on which the lazy getter will be defined.
266   * @param name
267   *        The name of the getter to define.
268   * @param moduleUrl
269   *        The URL used to obtain the module.
270   * @param symbol [optional]
271   *        The name of the symbol exported by the module. This can be omitted
272   *        if the name of the exported symbol is equal to the getter name.
273   */
274  defineModuleGetter(targetObject, name, moduleUrl, symbol) {
275    let moduleHolder = {};
276    XPCOMUtils.defineLazyModuleGetter(moduleHolder, name, moduleUrl, symbol);
277    Object.defineProperty(targetObject, name, {
278      get: () => this.getCombined(moduleHolder[name]),
279      configurable: true,
280      enumerable: true,
281    });
282  },
283};
284