1/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
2/* vim: set sts=2 sw=2 et tw=80: */
3/* This Source Code Form is subject to the terms of the Mozilla Public
4 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
5 * You can obtain one at http://mozilla.org/MPL/2.0/. */
6
7"use strict";
8
9const EXPORTED_SYMBOLS = ["ToolbarButtonAPI"];
10
11ChromeUtils.defineModuleGetter(
12  this,
13  "Services",
14  "resource://gre/modules/Services.jsm"
15);
16ChromeUtils.defineModuleGetter(
17  this,
18  "ViewPopup",
19  "resource:///modules/ExtensionPopups.jsm"
20);
21ChromeUtils.defineModuleGetter(
22  this,
23  "ExtensionSupport",
24  "resource:///modules/ExtensionSupport.jsm"
25);
26const { ExtensionCommon } = ChromeUtils.import(
27  "resource://gre/modules/ExtensionCommon.jsm"
28);
29const { ExtensionUtils } = ChromeUtils.import(
30  "resource://gre/modules/ExtensionUtils.jsm"
31);
32const { ExtensionParent } = ChromeUtils.import(
33  "resource://gre/modules/ExtensionParent.jsm"
34);
35
36var { EventManager, ExtensionAPI, makeWidgetId } = ExtensionCommon;
37
38var { IconDetails, StartupCache } = ExtensionParent;
39
40var { DefaultWeakMap, ExtensionError } = ExtensionUtils;
41
42const { XPCOMUtils } = ChromeUtils.import(
43  "resource://gre/modules/XPCOMUtils.jsm"
44);
45XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
46
47var DEFAULT_ICON = "chrome://messenger/content/extension.svg";
48
49var ToolbarButtonAPI = class extends ExtensionAPI {
50  constructor(extension, global) {
51    super(extension);
52    this.global = global;
53    this.tabContext = new this.global.TabContext(target =>
54      this.getContextData(null)
55    );
56  }
57
58  /**
59   * Called when the extension is enabled.
60   *
61   * @param {String} entryName
62   *        The name of the property in the extension manifest
63   */
64  async onManifestEntry(entryName) {
65    let { extension } = this;
66    this.paint = this.paint.bind(this);
67    this.unpaint = this.unpaint.bind(this);
68
69    this.widgetId = makeWidgetId(extension.id);
70    this.id = `${this.widgetId}-${this.manifestName}-toolbarbutton`;
71
72    this.eventQueue = [];
73
74    let options = extension.manifest[entryName];
75    this.defaults = {
76      enabled: true,
77      label: options.default_label,
78      title: options.default_title || extension.name,
79      badgeText: "",
80      badgeBackgroundColor: null,
81      popup: options.default_popup || "",
82    };
83    this.globals = Object.create(this.defaults);
84
85    // In tests, startupReason is undefined, because the test suite is naughty.
86    // Assume ADDON_INSTALL.
87    if (
88      !this.extension.startupReason ||
89      this.extension.startupReason == "ADDON_INSTALL"
90    ) {
91      for (let windowURL of this.windowURLs) {
92        let currentSet = Services.xulStore.getValue(
93          windowURL,
94          this.toolbarId,
95          "currentset"
96        );
97        if (!currentSet) {
98          continue;
99        }
100        currentSet = currentSet.split(",");
101        if (currentSet.includes(this.id)) {
102          continue;
103        }
104        currentSet.push(this.id);
105        Services.xulStore.setValue(
106          windowURL,
107          this.toolbarId,
108          "currentset",
109          currentSet.join(",")
110        );
111      }
112    }
113
114    this.browserStyle = options.browser_style;
115
116    this.defaults.icon = await StartupCache.get(
117      extension,
118      [this.manifestName, "default_icon"],
119      () =>
120        IconDetails.normalize(
121          {
122            path: options.default_icon,
123            iconType: this.manifestName,
124            themeIcons: options.theme_icons,
125          },
126          extension
127        )
128    );
129
130    this.iconData = new DefaultWeakMap(icons => this.getIconData(icons));
131    this.iconData.set(
132      this.defaults.icon,
133      await StartupCache.get(
134        extension,
135        [this.manifestName, "default_icon_data"],
136        () => this.getIconData(this.defaults.icon)
137      )
138    );
139
140    ExtensionSupport.registerWindowListener(this.id, {
141      chromeURLs: this.windowURLs,
142      onLoadWindow: window => {
143        this.paint(window);
144      },
145    });
146
147    extension.callOnClose(this);
148  }
149
150  /**
151   * Called when the extension is disabled or removed.
152   */
153  close() {
154    ExtensionSupport.unregisterWindowListener(this.id);
155    for (let window of ExtensionSupport.openWindows) {
156      if (this.windowURLs.includes(window.location.href)) {
157        this.unpaint(window);
158      }
159    }
160  }
161
162  /**
163   * Creates a toolbar button.
164   *
165   * @param {Window} window
166   */
167  makeButton(window) {
168    let { document } = window;
169    let button = document.createXULElement("toolbarbutton");
170    button.id = this.id;
171    button.classList.add("toolbarbutton-1");
172    button.classList.add("webextension-action");
173    button.setAttribute("badged", "true");
174    button.setAttribute("data-extensionid", this.extension.id);
175    button.addEventListener("mousedown", this);
176    this.updateButton(button, this.globals);
177    return button;
178  }
179
180  /**
181   * Adds a toolbar button to this window.
182   *
183   * @param {Window} window
184   */
185  paint(window) {
186    let windowURL = window.location.href;
187    let { document } = window;
188    if (document.getElementById(this.id)) {
189      return;
190    }
191
192    let toolbox = document.getElementById(this.toolboxId);
193    if (!toolbox) {
194      return;
195    }
196
197    // Get all toolbars which link to or are children of this.toolboxId
198    let toolbars = window.document.querySelectorAll(
199      `#${this.toolboxId} toolbar, toolbar[toolboxid="${this.toolboxId}"]`
200    );
201    for (let toolbar of toolbars) {
202      let currentSet = Services.xulStore
203        .getValue(windowURL, toolbar.id, "currentset")
204        .split(",");
205      if (currentSet.includes(this.id)) {
206        this.toolbarId = toolbar.id;
207        break;
208      }
209    }
210
211    let toolbar = document.getElementById(this.toolbarId);
212    let button = this.makeButton(window);
213    if (toolbox.palette) {
214      toolbox.palette.appendChild(button);
215    } else {
216      toolbar.appendChild(button);
217    }
218    if (
219      Services.xulStore.hasValue(
220        window.location.href,
221        this.toolbarId,
222        "currentset"
223      )
224    ) {
225      toolbar.currentSet = Services.xulStore.getValue(
226        window.location.href,
227        this.toolbarId,
228        "currentset"
229      );
230      toolbar.setAttribute("currentset", toolbar.currentSet);
231    } else {
232      let currentSet = toolbar.getAttribute("defaultset").split(",");
233      if (!currentSet.includes(this.id)) {
234        currentSet.push(this.id);
235        toolbar.currentSet = currentSet.join(",");
236        toolbar.setAttribute("currentset", toolbar.currentSet);
237        Services.xulStore.persist(toolbar, "currentset");
238      }
239    }
240
241    if (this.extension.hasPermission("menus")) {
242      document.addEventListener("popupshowing", this);
243    }
244  }
245
246  /**
247   * Removes the toolbar button from this window.
248   *
249   * @param {Window} window
250   */
251  unpaint(window) {
252    let { document } = window;
253
254    if (this.extension.hasPermission("menus")) {
255      document.removeEventListener("popupshowing", this);
256    }
257
258    let button = document.getElementById(this.id);
259    if (button) {
260      button.remove();
261    }
262  }
263
264  /**
265   * Triggers this browser action for the given window, with the same effects as
266   * if it were clicked by a user.
267   *
268   * This has no effect if the browser action is disabled for, or not
269   * present in, the given window.
270   *
271   * @param {Window} window
272   */
273  async triggerAction(window) {
274    let { document } = window;
275    let button = document.getElementById(this.id);
276    let { popup: popupURL, enabled } = this.getContextData(
277      this.getTargetFromWindow(window)
278    );
279
280    if (button && popupURL && enabled) {
281      let popup =
282        ViewPopup.for(this.extension, window) ||
283        this.getPopup(window, popupURL);
284      popup.viewNode.openPopup(button, "bottomcenter topleft", 0, 0);
285    } else {
286      if (!this.lastClickInfo) {
287        this.lastClickInfo = { button: 0, modifiers: [] };
288      }
289      this.emit("click", window);
290      delete this.lastClickInfo;
291    }
292  }
293
294  /**
295   * Event listener.
296   *
297   * @param {Event} event
298   */
299  handleEvent(event) {
300    let window = event.target.ownerGlobal;
301
302    switch (event.type) {
303      case "mousedown":
304        if (event.button == 0) {
305          this.lastClickInfo = {
306            button: 0,
307            modifiers: this.global.clickModifiersFromEvent(event),
308          };
309          this.triggerAction(window);
310        }
311        break;
312      case "TabSelect":
313        this.updateWindow(window);
314        break;
315    }
316  }
317
318  /**
319   * Returns a potentially pre-loaded popup for the given URL in the given
320   * window. If a matching pre-load popup already exists, returns that.
321   * Otherwise, initializes a new one.
322   *
323   * If a pre-load popup exists which does not match, it is destroyed before a
324   * new one is created.
325   *
326   * @param {Window} window
327   *        The browser window in which to create the popup.
328   * @param {string} popupURL
329   *        The URL to load into the popup.
330   * @param {boolean} [blockParser = false]
331   *        True if the HTML parser should initially be blocked.
332   * @returns {ViewPopup}
333   */
334  getPopup(window, popupURL, blockParser = false) {
335    let popup = new ViewPopup(
336      this.extension,
337      window,
338      popupURL,
339      this.browserStyle,
340      false,
341      blockParser
342    );
343    popup.ignoreResizes = false;
344    return popup;
345  }
346
347  /**
348   * Update the toolbar button |node| with the tab context data
349   * in |tabData|.
350   *
351   * @param {XULElement} node
352   *        XUL toolbarbutton to update
353   * @param {Object} tabData
354   *        Properties to set
355   * @param {boolean} sync
356   *        Whether to perform the update immediately
357   */
358  updateButton(node, tabData, sync = false) {
359    let title = tabData.title || this.extension.name;
360    let label = tabData.label;
361    let callback = () => {
362      node.setAttribute("tooltiptext", title);
363      node.setAttribute("label", label || title);
364      node.setAttribute(
365        "hideWebExtensionLabel",
366        label === "" ? "true" : "false"
367      );
368
369      if (tabData.badgeText) {
370        node.setAttribute("badge", tabData.badgeText);
371      } else {
372        node.removeAttribute("badge");
373      }
374
375      if (tabData.enabled) {
376        node.removeAttribute("disabled");
377      } else {
378        node.setAttribute("disabled", "true");
379      }
380
381      let color = tabData.badgeBackgroundColor;
382      if (color) {
383        color = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] /
384          255})`;
385        node.setAttribute("badgeStyle", `background-color: ${color};`);
386      } else {
387        node.removeAttribute("badgeStyle");
388      }
389
390      let { style, legacy } = this.iconData.get(tabData.icon);
391      const LEGACY_CLASS = "toolbarbutton-legacy-addon";
392      if (legacy) {
393        node.classList.add(LEGACY_CLASS);
394      } else {
395        node.classList.remove(LEGACY_CLASS);
396      }
397
398      for (let [name, value] of style) {
399        node.style.setProperty(name, value);
400      }
401    };
402    if (sync) {
403      callback();
404    } else {
405      node.ownerGlobal.requestAnimationFrame(callback);
406    }
407  }
408
409  /**
410   * Get icon properties for updating the UI.
411   *
412   * @param {Object} icons
413   *        Contains the icon information, typically the extension manifest
414   */
415  getIconData(icons) {
416    let baseSize = 16;
417    let { icon, size } = IconDetails.getPreferredIcon(
418      icons,
419      this.extension,
420      baseSize
421    );
422
423    let legacy = false;
424
425    // If the best available icon size is not divisible by 16, check if we have
426    // an 18px icon to fall back to, and trim off the padding instead.
427    if (size % 16 && typeof icon === "string" && !icon.endsWith(".svg")) {
428      let result = IconDetails.getPreferredIcon(icons, this.extension, 18);
429
430      if (result.size % 18 == 0) {
431        baseSize = 18;
432        icon = result.icon;
433        legacy = true;
434      }
435    }
436
437    let getIcon = (size, theme) => {
438      let { icon } = IconDetails.getPreferredIcon(icons, this.extension, size);
439      if (typeof icon === "object") {
440        if (icon[theme] == IconDetails.DEFAULT_ICON) {
441          icon[theme] = DEFAULT_ICON;
442        }
443        return IconDetails.escapeUrl(icon[theme]);
444      }
445      if (icon == IconDetails.DEFAULT_ICON) {
446        return DEFAULT_ICON;
447      }
448      return IconDetails.escapeUrl(icon);
449    };
450
451    let style = [];
452    let getStyle = (name, size) => {
453      style.push([
454        `--webextension-${name}`,
455        `url("${getIcon(size, "default")}")`,
456      ]);
457      style.push([
458        `--webextension-${name}-light`,
459        `url("${getIcon(size, "light")}")`,
460      ]);
461      style.push([
462        `--webextension-${name}-dark`,
463        `url("${getIcon(size, "dark")}")`,
464      ]);
465    };
466
467    getStyle("menupanel-image", 32);
468    getStyle("menupanel-image-2x", 64);
469    getStyle("toolbar-image", baseSize);
470    getStyle("toolbar-image-2x", baseSize * 2);
471
472    let realIcon = getIcon(size, "default");
473
474    return { style, legacy, realIcon };
475  }
476
477  /**
478   * Update the toolbar button for a given window.
479   *
480   * @param {ChromeWindow} window
481   *        Browser chrome window.
482   */
483  async updateWindow(window) {
484    let button = window.document.getElementById(this.id);
485    if (button) {
486      this.updateButton(
487        button,
488        this.getContextData(this.getTargetFromWindow(window))
489      );
490    }
491    await new Promise(window.requestAnimationFrame);
492  }
493
494  /**
495   * Update the toolbar button when the extension changes the icon, title, url, etc.
496   * If it only changes a parameter for a single tab, `target` will be that tab.
497   * If it only changes a parameter for a single window, `target` will be that window.
498   * Otherwise `target` will be null.
499   *
500   * @param {XULElement|ChromeWindow|null} target
501   *        Browser tab or browser chrome window, may be null.
502   */
503  async updateOnChange(target) {
504    if (target) {
505      let window = Cu.getGlobalForObject(target);
506      if (target === window) {
507        await this.updateWindow(window);
508      } else {
509        let tabmail = window.document.getElementById("tabmail");
510        if (tabmail && target == tabmail.selectedTab) {
511          await this.updateWindow(window);
512        }
513      }
514    } else {
515      let promises = [];
516      for (let window of ExtensionSupport.openWindows) {
517        if (this.windowURLs.includes(window.location.href)) {
518          promises.push(this.updateWindow(window));
519        }
520      }
521      await Promise.all(promises);
522    }
523  }
524
525  /**
526   * Gets the active tab of the passed window if the window has tabs, or the
527   * window itself.
528   *
529   * @param {ChromeWindow} window
530   * @returns {XULElement|ChromeWindow}
531   */
532  getTargetFromWindow(window) {
533    let tabmail = window.document.getElementById("tabmail");
534    if (tabmail) {
535      return tabmail.currentTabInfo;
536    }
537    return window;
538  }
539
540  /**
541   * Gets the target object corresponding to the `details` parameter of the various
542   * get* and set* API methods.
543   *
544   * @param {Object} details
545   *        An object with optional `tabId` or `windowId` properties.
546   * @throws if `windowId` is specified, this is not valid in Thunderbird.
547   * @returns {XULElement|ChromeWindow|null}
548   *        If a `tabId` was specified, the corresponding XULElement tab.
549   *        If a `windowId` was specified, the corresponding ChromeWindow.
550   *        Otherwise, `null`.
551   */
552  getTargetFromDetails({ tabId, windowId }) {
553    if (windowId != null) {
554      throw new ExtensionError("windowId is not allowed, use tabId instead.");
555    }
556    if (tabId != null) {
557      return this.global.tabTracker.getTab(tabId);
558    }
559    return null;
560  }
561
562  /**
563   * Gets the data associated with a tab, window, or the global one.
564   *
565   * @param {XULElement|ChromeWindow|null} target
566   *        A XULElement tab, a ChromeWindow, or null for the global data.
567   * @returns {Object}
568   *        The icon, title, badge, etc. associated with the target.
569   */
570  getContextData(target) {
571    if (target) {
572      return this.tabContext.get(target);
573    }
574    return this.globals;
575  }
576
577  /**
578   * Set a global, window specific or tab specific property.
579   *
580   * @param {Object} details
581   *        An object with optional `tabId` or `windowId` properties.
582   * @param {string} prop
583   *        String property to set. Should should be one of "icon", "title", "label",
584   *        "badgeText", "popup", "badgeBackgroundColor" or "enabled".
585   * @param {string} value
586   *        Value for prop.
587   */
588  async setProperty(details, prop, value) {
589    let target = this.getTargetFromDetails(details);
590    let values = this.getContextData(target);
591    if (value === null) {
592      delete values[prop];
593    } else {
594      values[prop] = value;
595    }
596
597    await this.updateOnChange(target);
598  }
599
600  /**
601   * Retrieve the value of a global, window specific or tab specific property.
602   *
603   * @param {Object} details
604   *        An object with optional `tabId` or `windowId` properties.
605   * @param {string} prop
606   *        String property to retrieve. Should should be one of "icon", "title", "label",
607   *        "badgeText", "popup", "badgeBackgroundColor" or "enabled".
608   * @returns {string} value
609   *          Value of prop.
610   */
611  getProperty(details, prop) {
612    return this.getContextData(this.getTargetFromDetails(details))[prop];
613  }
614
615  /**
616   * WebExtension API.
617   *
618   * @param {Object} context
619   */
620  getAPI(context) {
621    let { extension } = context;
622    let { tabManager, windowManager } = extension;
623
624    let action = this;
625
626    return {
627      [this.manifestName]: {
628        onClicked: new EventManager({
629          context,
630          name: `${this.manifestName}.onClicked`,
631          inputHandling: true,
632          register: fire => {
633            let listener = (event, window) => {
634              let win = windowManager.wrapWindow(window);
635              fire.sync(
636                tabManager.convert(win.activeTab.nativeTab),
637                this.lastClickInfo
638              );
639            };
640            action.on("click", listener);
641            return () => {
642              action.off("click", listener);
643            };
644          },
645        }).api(),
646
647        async enable(tabId) {
648          await action.setProperty({ tabId }, "enabled", true);
649        },
650
651        async disable(tabId) {
652          await action.setProperty({ tabId }, "enabled", false);
653        },
654
655        isEnabled(details) {
656          return action.getProperty(details, "enabled");
657        },
658
659        async setTitle(details) {
660          await action.setProperty(details, "title", details.title);
661        },
662
663        getTitle(details) {
664          return action.getProperty(details, "title");
665        },
666
667        async setLabel(details) {
668          await action.setProperty(details, "label", details.label);
669        },
670
671        getLabel(details) {
672          return action.getProperty(details, "label");
673        },
674
675        async setIcon(details) {
676          details.iconType = this.manifestName;
677
678          let icon = IconDetails.normalize(details, extension, context);
679          if (!Object.keys(icon).length) {
680            icon = null;
681          }
682          await action.setProperty(details, "icon", icon);
683        },
684
685        async setBadgeText(details) {
686          await action.setProperty(details, "badgeText", details.text);
687        },
688
689        getBadgeText(details) {
690          return action.getProperty(details, "badgeText");
691        },
692
693        async setPopup(details) {
694          // Note: Chrome resolves arguments to setIcon relative to the calling
695          // context, but resolves arguments to setPopup relative to the extension
696          // root.
697          // For internal consistency, we currently resolve both relative to the
698          // calling context.
699          let url = details.popup && context.uri.resolve(details.popup);
700          if (url && !context.checkLoadURL(url)) {
701            return Promise.reject({ message: `Access denied for URL ${url}` });
702          }
703          await action.setProperty(details, "popup", url);
704          return Promise.resolve(null);
705        },
706
707        getPopup(details) {
708          return action.getProperty(details, "popup");
709        },
710
711        async setBadgeBackgroundColor(details) {
712          let color = details.color;
713          if (typeof color == "string") {
714            let col = InspectorUtils.colorToRGBA(color);
715            if (!col) {
716              throw new ExtensionError(
717                `Invalid badge background color: "${color}"`
718              );
719            }
720            color = col && [col.r, col.g, col.b, Math.round(col.a * 255)];
721          }
722          await action.setProperty(details, "badgeBackgroundColor", color);
723        },
724
725        getBadgeBackgroundColor(details, callback) {
726          let color = action.getProperty(details, "badgeBackgroundColor");
727          return color || [0xd9, 0, 0, 255];
728        },
729
730        openPopup() {
731          let window = Services.wm.getMostRecentWindow("");
732          if (action.windowURLs.includes(window.location.href)) {
733            action.triggerAction(window);
734          }
735        },
736      },
737    };
738  }
739};
740