ext-sidebarAction.js (14733B)
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 9 var { ExtensionParent } = ChromeUtils.importESModule( 10 "resource://gre/modules/ExtensionParent.sys.mjs" 11 ); 12 var { ExtensionError } = ExtensionUtils; 13 14 var { IconDetails } = ExtensionParent; 15 16 ChromeUtils.defineESModuleGetters(this, { 17 SidebarManager: 18 "moz-src:///browser/components/sidebar/SidebarManager.sys.mjs", 19 }); 20 21 // WeakMap[Extension -> SidebarAction] 22 let sidebarActionMap = new WeakMap(); 23 24 /** 25 * Responsible for the sidebar_action section of the manifest as well 26 * as the associated sidebar browser. 27 */ 28 this.sidebarAction = class extends ExtensionAPI { 29 static for(extension) { 30 return sidebarActionMap.get(extension); 31 } 32 33 onManifestEntry() { 34 let { extension } = this; 35 36 extension.once("ready", this.onReady.bind(this)); 37 38 let options = extension.manifest.sidebar_action; 39 40 // Add the extension to the sidebar menu. The sidebar widget will copy 41 // from that when it is viewed, so we shouldn't need to update that. 42 let widgetId = makeWidgetId(extension.id); 43 this.id = `${widgetId}-sidebar-action`; 44 this.menuId = `menubar_menu_${this.id}`; 45 46 this.browserStyle = options.browser_style; 47 48 this.defaults = { 49 enabled: true, 50 title: options.default_title || extension.name, 51 icon: IconDetails.normalize({ path: options.default_icon }, extension), 52 panel: options.default_panel || "", 53 }; 54 this.globals = Object.create(this.defaults); 55 56 this.tabContext = new TabContext(target => { 57 let window = target.ownerGlobal; 58 if (target === window) { 59 return this.globals; 60 } 61 return this.tabContext.get(window); 62 }); 63 64 // We need to ensure our elements are available before session restore. 65 this.windowOpenListener = window => { 66 this.createMenuItem(window, this.globals); 67 }; 68 windowTracker.addOpenListener(this.windowOpenListener); 69 70 sidebarActionMap.set(extension, this); 71 } 72 73 onReady() { 74 this.build(); 75 } 76 77 /** 78 * Called by any extension when any of the following happens: 79 * - An extension has an update including whether it is a sidebar 80 * - An extension is disabled or removed 81 * - On browser shutdown 82 * 83 * @param {boolean} isAppShutdown 84 * Whether this is called during app shutdown 85 */ 86 onShutdown(isAppShutdown) { 87 if (!sidebarActionMap.delete(this.extension)) { 88 // sidebar_action not specified for this extension. 89 return; 90 } 91 this.tabContext.shutdown(); 92 // Don't remove everything on app shutdown so session restore can handle 93 // restoring open sidebars. 94 if (isAppShutdown) { 95 return; 96 } 97 98 for (let window of windowTracker.browserWindows()) { 99 let { SidebarController } = window; 100 // Note: sidebar preferences such as sidebar.installed.extensions are kept to remember users preferences 101 // and should be remembered between browser/extension restarts, when the extension is disabled and re-enabled, 102 // and across updates (including updates that drop sidebar_action). We should only forget about these on uninstall. 103 SidebarController.removeExtension(this.id); 104 } 105 windowTracker.removeOpenListener(this.windowOpenListener); 106 } 107 108 static onUninstall(id) { 109 const sidebarId = `${makeWidgetId(id)}-sidebar-action`; 110 111 let installedExtensions = Services.prefs 112 .getStringPref("sidebar.installed.extensions", "") 113 .split(","); 114 const index = installedExtensions.indexOf(id); 115 if (index != -1) { 116 SidebarManager.cleanupPrefs(id); 117 } 118 119 for (let window of windowTracker.browserWindows()) { 120 let { SidebarController } = window; 121 if (SidebarController.lastOpenedId === sidebarId) { 122 SidebarController.lastOpenedId = null; 123 } 124 } 125 } 126 127 build() { 128 // eslint-disable-next-line mozilla/balanced-listeners 129 this.tabContext.on("tab-select", (evt, tab) => { 130 this.updateWindow(tab.ownerGlobal); 131 }); 132 133 let install = this.extension.startupReason === "ADDON_INSTALL"; 134 for (let window of windowTracker.browserWindows()) { 135 this.updateWindow(window); 136 let { SidebarController } = window; 137 if ( 138 (install || SidebarController.lastOpenedId == this.id) && 139 this.extension.manifest.sidebar_action.open_at_install 140 ) { 141 SidebarController.show(this.id); 142 } 143 } 144 } 145 146 createMenuItem(window, details) { 147 if (!this.extension.canAccessWindow(window)) { 148 return; 149 } 150 this.panel = details.panel; 151 let { SidebarController, devicePixelRatio } = window; 152 SidebarController.registerExtension(this.id, { 153 ...this.getMenuIcon(details, devicePixelRatio), 154 menuId: this.menuId, 155 title: details.title, 156 extensionId: this.extension.id, 157 onload: () => 158 SidebarController.browser.contentWindow.loadPanel( 159 this.extension.id, 160 this.panel, 161 this.browserStyle 162 ), 163 }); 164 } 165 166 /** 167 * Retrieve the icon to be rendered in sidebar menus. 168 * 169 * @param {object} details 170 * @param {object} details.icon 171 * Extension icons. 172 * @param {number} scale 173 * Scaling factor of the icon's size. 174 * @returns {{ icon: string; iconUrl: string }} 175 */ 176 getMenuIcon({ icon }, scale) { 177 let getIcon = size => 178 IconDetails.escapeUrl( 179 IconDetails.getPreferredIcon(icon, this.extension, size).icon 180 ); 181 182 const iconUrl = getIcon(16 * scale); 183 // TODO Bug 1898257 - Only return iconUrl here, remove usages of icon. 184 return { 185 icon: `image-set(url("${getIcon(16)}"), url("${getIcon(32)}") 2x)`, 186 iconUrl, 187 }; 188 } 189 190 /** 191 * Update the menu items with the tab context data in `tabData`. 192 * 193 * @param {ChromeWindow} window 194 * Browser chrome window. 195 * @param {object} tabData 196 * Tab specific sidebar configuration. 197 */ 198 updateButton(window, tabData) { 199 let { document, SidebarController, devicePixelRatio } = window; 200 let title = tabData.title || this.extension.name; 201 if (!document.getElementById(this.menuId)) { 202 // Menu items are added when new windows are opened, or from onReady (when 203 // an extension has fully started). The menu item may be missing at this 204 // point if the extension updates the sidebar during its startup. 205 this.createMenuItem(window, tabData); 206 } 207 let urlChanged = tabData.panel !== this.panel; 208 if (urlChanged) { 209 this.panel = tabData.panel; 210 } 211 SidebarController.setExtensionAttributes( 212 this.id, 213 { 214 ...this.getMenuIcon(tabData, devicePixelRatio), 215 label: title, 216 }, 217 urlChanged 218 ); 219 } 220 221 /** 222 * Update the menu items for a given window. 223 * 224 * @param {ChromeWindow} window 225 * Browser chrome window. 226 */ 227 updateWindow(window) { 228 if (!this.extension.canAccessWindow(window)) { 229 return; 230 } 231 let nativeTab = window.gBrowser.selectedTab; 232 this.updateButton(window, this.tabContext.get(nativeTab)); 233 } 234 235 /** 236 * Update the menu items when the extension changes the icon, 237 * title, url, etc. If it only changes a parameter for a single tab, `target` 238 * will be that tab. If it only changes a parameter for a single window, 239 * `target` will be that window. Otherwise `target` will be null. 240 * 241 * @param {XULElement|ChromeWindow|null} target 242 * Browser tab or browser chrome window, may be null. 243 */ 244 updateOnChange(target) { 245 if (target) { 246 let window = target.ownerGlobal; 247 if (target === window || target.selected) { 248 this.updateWindow(window); 249 } 250 } else { 251 for (let window of windowTracker.browserWindows()) { 252 this.updateWindow(window); 253 } 254 } 255 } 256 257 /** 258 * Gets the target object corresponding to the `details` parameter of the various 259 * get* and set* API methods. 260 * 261 * @param {object} details 262 * An object with optional `tabId` or `windowId` properties. 263 * @param {number} [details.tabId] 264 * The target tab. 265 * @param {number} [details.windowId] 266 * The target window. 267 * @throws if both `tabId` and `windowId` are specified, or if they are invalid. 268 * @returns {XULElement|ChromeWindow|null} 269 * If a `tabId` was specified, the corresponding XULElement tab. 270 * If a `windowId` was specified, the corresponding ChromeWindow. 271 * Otherwise, `null`. 272 */ 273 getTargetFromDetails({ tabId, windowId }) { 274 if (tabId != null && windowId != null) { 275 throw new ExtensionError( 276 "Only one of tabId and windowId can be specified." 277 ); 278 } 279 let target = null; 280 if (tabId != null) { 281 target = tabTracker.getTab(tabId); 282 if (!this.extension.canAccessWindow(target.ownerGlobal)) { 283 throw new ExtensionError(`Invalid tab ID: ${tabId}`); 284 } 285 } else if (windowId != null) { 286 target = windowTracker.getWindow(windowId); 287 if (!this.extension.canAccessWindow(target)) { 288 throw new ExtensionError(`Invalid window ID: ${windowId}`); 289 } 290 } 291 return target; 292 } 293 294 /** 295 * Gets the data associated with a tab, window, or the global one. 296 * 297 * @param {XULElement|ChromeWindow|null} target 298 * A XULElement tab, a ChromeWindow, or null for the global data. 299 * @returns {object} 300 * The icon, title, panel, etc. associated with the target. 301 */ 302 getContextData(target) { 303 if (target) { 304 return this.tabContext.get(target); 305 } 306 return this.globals; 307 } 308 309 /** 310 * Set a global, window specific or tab specific property. 311 * 312 * @param {XULElement|ChromeWindow|null} target 313 * A XULElement tab, a ChromeWindow, or null for the global data. 314 * @param {string} prop 315 * String property to set ["icon", "title", or "panel"]. 316 * @param {string} value 317 * Value for property. 318 */ 319 setProperty(target, prop, value) { 320 let values = this.getContextData(target); 321 if (value === null) { 322 delete values[prop]; 323 } else { 324 values[prop] = value; 325 } 326 327 this.updateOnChange(target); 328 } 329 330 /** 331 * Retrieve the value of a global, window specific or tab specific property. 332 * 333 * @param {XULElement|ChromeWindow|null} target 334 * A XULElement tab, a ChromeWindow, or null for the global data. 335 * @param {string} prop 336 * String property to retrieve ["icon", "title", or "panel"] 337 * @returns {string} value 338 * Value of prop. 339 */ 340 getProperty(target, prop) { 341 return this.getContextData(target)[prop]; 342 } 343 344 setPropertyFromDetails(details, prop, value) { 345 return this.setProperty(this.getTargetFromDetails(details), prop, value); 346 } 347 348 getPropertyFromDetails(details, prop) { 349 return this.getProperty(this.getTargetFromDetails(details), prop); 350 } 351 352 /** 353 * Triggers this sidebar action for the given window, with the same effects as 354 * if it were toggled via menu or toolbarbutton by a user. 355 * 356 * @param {ChromeWindow} window 357 */ 358 triggerAction(window) { 359 let { SidebarController } = window; 360 if (SidebarController && this.extension.canAccessWindow(window)) { 361 SidebarController.toggle(this.id); 362 } 363 } 364 365 /** 366 * Opens this sidebar action for the given window. 367 * 368 * @param {ChromeWindow} window 369 */ 370 open(window) { 371 let { SidebarController } = window; 372 if (SidebarController && this.extension.canAccessWindow(window)) { 373 SidebarController.show(this.id); 374 } 375 } 376 377 /** 378 * Closes this sidebar action for the given window if this sidebar action is open. 379 * 380 * @param {ChromeWindow} window 381 */ 382 close(window) { 383 if (this.isOpen(window)) { 384 window.SidebarController.hide(); 385 } 386 } 387 388 /** 389 * Toogles this sidebar action for the given window 390 * 391 * @param {ChromeWindow} window 392 */ 393 toggle(window) { 394 let { SidebarController } = window; 395 if (!SidebarController || !this.extension.canAccessWindow(window)) { 396 return; 397 } 398 399 if (!this.isOpen(window)) { 400 SidebarController.show(this.id); 401 } else { 402 SidebarController.hide(); 403 } 404 } 405 406 /** 407 * Checks whether this sidebar action is open in the given window. 408 * 409 * @param {ChromeWindow} window 410 * @returns {boolean} 411 */ 412 isOpen(window) { 413 let { SidebarController } = window; 414 return SidebarController.isOpen && this.id == SidebarController.currentID; 415 } 416 417 getAPI(context) { 418 let { extension } = context; 419 const sidebarAction = this; 420 421 return { 422 sidebarAction: { 423 async setTitle(details) { 424 sidebarAction.setPropertyFromDetails(details, "title", details.title); 425 }, 426 427 getTitle(details) { 428 return sidebarAction.getPropertyFromDetails(details, "title"); 429 }, 430 431 async setIcon(details) { 432 let icon = IconDetails.normalize(details, extension, context); 433 if (!Object.keys(icon).length) { 434 icon = null; 435 } 436 sidebarAction.setPropertyFromDetails(details, "icon", icon); 437 }, 438 439 async setPanel(details) { 440 let url; 441 // Clear the url when given null or empty string. 442 if (!details.panel) { 443 url = null; 444 } else { 445 url = context.uri.resolve(details.panel); 446 if (!context.checkLoadURL(url)) { 447 return Promise.reject({ 448 message: `Access denied for URL ${url}`, 449 }); 450 } 451 } 452 453 sidebarAction.setPropertyFromDetails(details, "panel", url); 454 }, 455 456 getPanel(details) { 457 return sidebarAction.getPropertyFromDetails(details, "panel"); 458 }, 459 460 open() { 461 let window = windowTracker.topWindow; 462 if (context.canAccessWindow(window)) { 463 sidebarAction.open(window); 464 } 465 }, 466 467 close() { 468 let window = windowTracker.topWindow; 469 if (context.canAccessWindow(window)) { 470 sidebarAction.close(window); 471 } 472 }, 473 474 toggle() { 475 let window = windowTracker.topWindow; 476 if (context.canAccessWindow(window)) { 477 sidebarAction.toggle(window); 478 } 479 }, 480 481 isOpen(details) { 482 let { windowId } = details; 483 if (windowId == null) { 484 windowId = Window.WINDOW_ID_CURRENT; 485 } 486 let window = windowTracker.getWindow(windowId, context); 487 return sidebarAction.isOpen(window); 488 }, 489 }, 490 }; 491 } 492 }; 493 494 global.sidebarActionFor = this.sidebarAction.for;