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