summaryrefslogtreecommitdiffstats
path: root/browser/components/extensions/ExtensionControlledPopup.sys.mjs
blob: 270dd65827e483ce75a0cf347d40921c44d33a82 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
/* 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/. */

/*
 * @fileOverview
 * This module exports a class that can be used to handle displaying a popup
 * doorhanger with a primary action to not show a popup for this extension again
 * and a secondary action disables the addon, or brings the user to their settings.
 *
 * The original purpose of the popup was to notify users of an extension that has
 * changed the New Tab or homepage. Users would see this popup the first time they
 * view those pages after a change to the setting in each session until they confirm
 * the change by triggering the primary action.
 */

import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs",
  CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
  ExtensionSettingsStore:
    "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});

let { makeWidgetId } = ExtensionCommon;

ChromeUtils.defineLazyGetter(lazy, "strBundle", function () {
  return Services.strings.createBundle(
    "chrome://global/locale/extensions.properties"
  );
});

const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon.";

ChromeUtils.defineLazyGetter(lazy, "distributionAddonsList", function () {
  let addonList = Services.prefs
    .getChildList(PREF_BRANCH_INSTALLED_ADDON)
    .map(id => id.replace(PREF_BRANCH_INSTALLED_ADDON, ""));
  return new Set(addonList);
});

export class ExtensionControlledPopup {
  /* Provide necessary options for the popup.
   *
   * @param {object} opts Options for configuring popup.
   * @param {string} opts.confirmedType
   *                 The type to use for storing a user's confirmation in
   *                 ExtensionSettingsStore.
   * @param {string} opts.observerTopic
   *                 An observer topic to trigger the popup on with Services.obs. If the
   *                 doorhanger should appear on a specific window include it as the
   *                 subject in the observer event.
   * @param {string} opts.anchorId
   *                 The id to anchor the popupnotification on. If it is not provided
   *                 then it will anchor to a browser action or the app menu.
   * @param {string} opts.popupnotificationId
   *                 The id for the popupnotification element in the markup. This
   *                 element should be defined in panelUI.inc.xhtml.
   * @param {string} opts.settingType
   *                 The setting type to check in ExtensionSettingsStore to retrieve
   *                 the controlling extension.
   * @param {string} opts.settingKey
   *                 The setting key to check in ExtensionSettingsStore to retrieve
   *                 the controlling extension.
   * @param {string} opts.descriptionId
   *                 The id of the element where the description should be displayed.
   * @param {string} opts.descriptionMessageId
   *                 The message id to be used for the description. The translated
   *                 string will have the add-on's name and icon injected into it.
   * @param {string} opts.getLocalizedDescription
   *                 A function to get the localized message string. This
   *                 function is passed doc, message and addonDetails (the
   *                 add-on's icon and name). If not provided, then the add-on's
   *                 icon and name are added to the description.
   * @param {string} opts.learnMoreLink
   *                 The name of the SUMO page to link to, this is added to
   *                 app.support.baseURL.
   * @param optional {string} opts.preferencesLocation
   *                 If included, the name of the preferences tab that will be opened
   *                 by the secondary action. If not included, the secondary option will
   *                 disable the addon.
   * @param optional {string} opts.preferencesEntrypoint
   *                 The entrypoint to pass to preferences telemetry.
   * @param {function} opts.onObserverAdded
   *                   A callback that is triggered when an observer is registered to
   *                   trigger the popup on the next observerTopic.
   * @param {function} opts.onObserverRemoved
   *                   A callback that is triggered when the observer is removed,
   *                   either because the popup is opening or it was explicitly
   *                   cancelled by calling removeObserver.
   * @param {function} opts.beforeDisableAddon
   *                   A function that is called before disabling an extension when the
   *                   user decides to disable the extension. If this function is async
   *                   then the extension won't be disabled until it is fulfilled.
   *                   This function gets two arguments, the ExtensionControlledPopup
   *                   instance for the panel and the window that the popup appears on.
   */
  constructor(opts) {
    this.confirmedType = opts.confirmedType;
    this.observerTopic = opts.observerTopic;
    this.anchorId = opts.anchorId;
    this.popupnotificationId = opts.popupnotificationId;
    this.settingType = opts.settingType;
    this.settingKey = opts.settingKey;
    this.descriptionId = opts.descriptionId;
    this.descriptionMessageId = opts.descriptionMessageId;
    this.getLocalizedDescription = opts.getLocalizedDescription;
    this.learnMoreLink = opts.learnMoreLink;
    this.preferencesLocation = opts.preferencesLocation;
    this.preferencesEntrypoint = opts.preferencesEntrypoint;
    this.onObserverAdded = opts.onObserverAdded;
    this.onObserverRemoved = opts.onObserverRemoved;
    this.beforeDisableAddon = opts.beforeDisableAddon;
    this.observerRegistered = false;
  }

  get topWindow() {
    return Services.wm.getMostRecentWindow("navigator:browser");
  }

  userHasConfirmed(id) {
    // We don't show a doorhanger for distribution installed add-ons.
    if (lazy.distributionAddonsList.has(id)) {
      return true;
    }
    let setting = lazy.ExtensionSettingsStore.getSetting(
      this.confirmedType,
      id
    );
    return !!(setting && setting.value);
  }

  async setConfirmation(id) {
    await lazy.ExtensionSettingsStore.initialize();
    return lazy.ExtensionSettingsStore.addSetting(
      id,
      this.confirmedType,
      id,
      true,
      () => false
    );
  }

  async clearConfirmation(id) {
    await lazy.ExtensionSettingsStore.initialize();
    return lazy.ExtensionSettingsStore.removeSetting(
      id,
      this.confirmedType,
      id
    );
  }

  observe(subject, topic, data) {
    // Remove the observer here so we don't get multiple open() calls if we get
    // multiple observer events in quick succession.
    this.removeObserver();

    let targetWindow;
    // Some notifications (e.g. browser-open-newtab-start) do not have a window subject.
    if (subject && subject.document) {
      targetWindow = subject;
    }

    // Do this work in an idle callback to avoid interfering with new tab performance tracking.
    this.topWindow.requestIdleCallback(() => this.open(targetWindow));
  }

  removeObserver() {
    if (this.observerRegistered) {
      Services.obs.removeObserver(this, this.observerTopic);
      this.observerRegistered = false;
      if (this.onObserverRemoved) {
        this.onObserverRemoved();
      }
    }
  }

  async addObserver(extensionId) {
    await lazy.ExtensionSettingsStore.initialize();

    if (!this.observerRegistered && !this.userHasConfirmed(extensionId)) {
      Services.obs.addObserver(this, this.observerTopic);
      this.observerRegistered = true;
      if (this.onObserverAdded) {
        this.onObserverAdded();
      }
    }
  }

  // The extensionId will be looked up in ExtensionSettingsStore if it is not
  // provided using this.settingType and this.settingKey.
  async open(targetWindow, extensionId) {
    await lazy.ExtensionSettingsStore.initialize();

    // Remove the observer since it would open the same dialog again the next time
    // the observer event fires.
    this.removeObserver();

    if (!extensionId) {
      let item = lazy.ExtensionSettingsStore.getSetting(
        this.settingType,
        this.settingKey
      );
      extensionId = item && item.id;
    }

    let win = targetWindow || this.topWindow;
    let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(win);
    if (
      isPrivate &&
      extensionId &&
      !WebExtensionPolicy.getByID(extensionId).privateBrowsingAllowed
    ) {
      return;
    }

    // The item should have an extension and the user shouldn't have confirmed
    // the change here, but just to be sure check that it is still controlled
    // and the user hasn't already confirmed the change.
    // If there is no id, then the extension is no longer in control.
    if (!extensionId || this.userHasConfirmed(extensionId)) {
      return;
    }

    // If the window closes while waiting for focus, this might reject/throw,
    // and we should stop trying to show the popup.
    try {
      await this._ensureWindowReady(win);
    } catch (ex) {
      return;
    }

    win.ownerGlobal.ensureCustomElements("moz-support-link");

    // Find the elements we need.
    let doc = win.document;
    let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(doc);
    let popupnotification = doc.getElementById(this.popupnotificationId);
    let urlBarWasFocused = win.gURLBar.focused;

    if (!popupnotification) {
      throw new Error(
        `No popupnotification found for id "${this.popupnotificationId}"`
      );
    }

    let elementsToTranslate = panel.querySelectorAll("[data-lazy-l10n-id]");
    if (elementsToTranslate.length) {
      win.MozXULElement.insertFTLIfNeeded("browser/appMenuNotifications.ftl");
      for (let el of elementsToTranslate) {
        el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id"));
        el.removeAttribute("data-lazy-l10n-id");
      }
      await win.document.l10n.translateFragment(panel);
    }
    let addon = await lazy.AddonManager.getAddonByID(extensionId);
    this.populateDescription(doc, addon);

    // Setup the command handler.
    let handleCommand = async event => {
      panel.hidePopup();
      if (event.originalTarget == popupnotification.button) {
        // Main action is to keep changes.
        await this.setConfirmation(extensionId);
      } else if (this.preferencesLocation) {
        // Secondary action opens Preferences, if a preferencesLocation option is included.
        let options = this.Entrypoint
          ? { urlParams: { entrypoint: this.Entrypoint } }
          : {};
        win.openPreferences(this.preferencesLocation, options);
      } else {
        // Secondary action is to restore settings.
        if (this.beforeDisableAddon) {
          await this.beforeDisableAddon(this, win);
        }
        await addon.disable();
      }

      // If the page this is appearing on is the New Tab page then the URL bar may
      // have been focused when the doorhanger stole focus away from it. Once an
      // action is taken the focus state should be restored to what the user was
      // expecting.
      if (urlBarWasFocused) {
        win.gURLBar.focus();
      }
    };
    panel.addEventListener("command", handleCommand);
    panel.addEventListener(
      "popuphidden",
      () => {
        popupnotification.hidden = true;
        panel.removeEventListener("command", handleCommand);
      },
      { once: true }
    );

    let anchorButton;
    if (this.anchorId) {
      // If there's an anchorId, use that right away.
      anchorButton = doc.getElementById(this.anchorId);
    } else {
      // Look for a browserAction on the toolbar.
      let action = lazy.CustomizableUI.getWidget(
        `${makeWidgetId(extensionId)}-browser-action`
      );
      if (action) {
        action =
          action.areaType == "toolbar" &&
          action.forWindow(win).node.firstElementChild;
      }

      // Anchor to a toolbar browserAction if found, otherwise use the menu button.
      anchorButton = action || doc.getElementById("PanelUI-menu-button");
    }
    let anchor = anchorButton.icon;
    popupnotification.show();
    panel.openPopup(anchor);
  }

  getAddonDetails(doc, addon) {
    const defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg";

    let image = doc.createXULElement("image");
    image.setAttribute("src", addon.iconURL || defaultIcon);
    image.classList.add("extension-controlled-icon");

    let addonDetails = doc.createDocumentFragment();
    addonDetails.appendChild(image);
    addonDetails.appendChild(doc.createTextNode(" " + addon.name));

    return addonDetails;
  }

  populateDescription(doc, addon) {
    let description = doc.getElementById(this.descriptionId);
    description.textContent = "";

    let addonDetails = this.getAddonDetails(doc, addon);
    let message = lazy.strBundle.GetStringFromName(this.descriptionMessageId);
    if (this.getLocalizedDescription) {
      description.appendChild(
        this.getLocalizedDescription(doc, message, addonDetails)
      );
    } else {
      description.appendChild(
        lazy.BrowserUIUtils.getLocalizedFragment(doc, message, addonDetails)
      );
    }

    let link = doc.createElement("a", { is: "moz-support-link" });
    link.setAttribute("class", "learnMore");
    link.setAttribute("support-page", this.learnMoreLink);
    description.appendChild(link);
  }

  async _ensureWindowReady(win) {
    if (win.closed) {
      throw new Error("window is closed");
    }
    let promises = [];
    let listenersToRemove = [];
    function promiseEvent(type) {
      promises.push(
        new Promise(resolve => {
          let listener = () => {
            win.removeEventListener(type, listener);
            resolve();
          };
          win.addEventListener(type, listener);
          listenersToRemove.push([type, listener]);
        })
      );
    }
    let { focusedWindow, activeWindow } = Services.focus;
    if (activeWindow != win) {
      promiseEvent("activate");
    }
    if (focusedWindow) {
      // We may have focused a non-remote child window, find the browser window:
      let { rootTreeItem } = focusedWindow.docShell;
      rootTreeItem.QueryInterface(Ci.nsIDocShell);
      focusedWindow = rootTreeItem.docViewer.DOMDocument.defaultView;
    }
    if (focusedWindow != win) {
      promiseEvent("focus");
    }
    if (promises.length) {
      let unloadListener;
      let unloadPromise = new Promise((resolve, reject) => {
        unloadListener = () => {
          for (let [type, listener] of listenersToRemove) {
            win.removeEventListener(type, listener);
          }
          reject(new Error("window unloaded"));
        };
        win.addEventListener("unload", unloadListener, { once: true });
      });
      try {
        let allPromises = Promise.all(promises);
        await Promise.race([allPromises, unloadPromise]);
      } finally {
        win.removeEventListener("unload", unloadListener);
      }
    }
  }

  static _getAndMaybeCreatePanel(doc) {
    // // Lazy load the extension-notification panel the first time we need to display it.
    let template = doc.getElementById("extensionNotificationTemplate");
    if (template) {
      template.replaceWith(template.content);
    }

    return doc.getElementById("extension-notification-panel");
  }
}