UITour.sys.mjs (62987B)
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 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs", 11 BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", 12 CustomizableUI: 13 "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs", 14 FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs", 15 PanelMultiView: 16 "moz-src:///browser/components/customizableui/PanelMultiView.sys.mjs", 17 ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", 18 ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs", 19 UIState: "resource://services-sync/UIState.sys.mjs", 20 UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", 21 }); 22 23 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { 24 return ChromeUtils.importESModule( 25 "resource://gre/modules/FxAccounts.sys.mjs" 26 ).getFxAccountsSingleton(); 27 }); 28 29 // See LOG_LEVELS in Console.sys.mjs. Common examples: "All", "Info", "Warn", & 30 // "Error". 31 const PREF_LOG_LEVEL = "browser.uitour.loglevel"; 32 33 const BACKGROUND_PAGE_ACTIONS_ALLOWED = new Set([ 34 "forceShowReaderIcon", 35 "getConfiguration", 36 "getTreatmentTag", 37 "hideHighlight", 38 "hideInfo", 39 "hideMenu", 40 "ping", 41 "registerPageID", 42 "setConfiguration", 43 "setTreatmentTag", 44 ]); 45 const MAX_BUTTONS = 4; 46 47 // Prefix for any target matching a search engine. 48 const TARGET_SEARCHENGINE_PREFIX = "searchEngine-"; 49 50 // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref. 51 ChromeUtils.defineLazyGetter(lazy, "log", () => { 52 let { ConsoleAPI } = ChromeUtils.importESModule( 53 "resource://gre/modules/Console.sys.mjs" 54 ); 55 let consoleOptions = { 56 maxLogLevelPref: PREF_LOG_LEVEL, 57 prefix: "UITour", 58 }; 59 return new ConsoleAPI(consoleOptions); 60 }); 61 62 export var UITour = { 63 url: null, 64 /* Map from browser chrome windows to a Set of <browser>s in which a tour is open (both visible and hidden) */ 65 tourBrowsersByWindow: new WeakMap(), 66 // Menus opened by api users explictly through `Mozilla.UITour.showMenu` call 67 noautohideMenus: new Set(), 68 availableTargetsCache: new WeakMap(), 69 clearAvailableTargetsCache() { 70 this.availableTargetsCache = new WeakMap(); 71 }, 72 73 _annotationPanelMutationObservers: new WeakMap(), 74 75 _initForBrowserObserverAdded: false, 76 77 highlightEffects: ["random", "wobble", "zoom", "color", "focus-outline"], 78 targets: new Map([ 79 [ 80 "accountStatus", 81 { 82 query: "#appMenu-fxa-label2", 83 // This is a fake widgetName starting with the "appMenu-" prefix so we know 84 // to automatically open the appMenu when annotating this target. 85 widgetName: "appMenu-fxa-label2", 86 }, 87 ], 88 [ 89 "addons", 90 { 91 query: "#appMenu-extensions-themes-button", 92 }, 93 ], 94 [ 95 "appMenu", 96 { 97 addTargetListener: (aDocument, aCallback) => { 98 let panelPopup = aDocument.defaultView.PanelUI.panel; 99 panelPopup.addEventListener("popupshown", aCallback); 100 }, 101 query: "#PanelUI-button", 102 removeTargetListener: (aDocument, aCallback) => { 103 let panelPopup = aDocument.defaultView.PanelUI.panel; 104 panelPopup.removeEventListener("popupshown", aCallback); 105 }, 106 }, 107 ], 108 ["backForward", { query: "#back-button" }], 109 ["bookmarks", { query: "#bookmarks-menu-button" }], 110 [ 111 "forget", 112 { 113 allowAdd: true, 114 query: "#panic-button", 115 widgetName: "panic-button", 116 }, 117 ], 118 ["help", { query: "#appMenu-help-button2" }], 119 ["home", { query: "#home-button" }], 120 [ 121 "logins", 122 { 123 query: "#appMenu-passwords-button", 124 }, 125 ], 126 [ 127 "privateWindow", 128 { 129 query: "#appMenu-new-private-window-button2", 130 }, 131 ], 132 [ 133 "quit", 134 { 135 query: "#appMenu-quit-button2", 136 }, 137 ], 138 ["readerMode-urlBar", { query: "#reader-mode-button" }], 139 [ 140 "search", 141 { 142 infoPanelOffsetX: 18, 143 infoPanelPosition: "after_start", 144 query: Services.prefs.getBoolPref("browser.search.widget.new") 145 ? "#searchbar-new" 146 : "#searchbar", 147 widgetName: "search-container", 148 }, 149 ], 150 [ 151 "searchIcon", 152 { 153 query: aDocument => { 154 if (!Services.prefs.getBoolPref("browser.search.widget.new")) { 155 let searchbar = aDocument.getElementById("searchbar"); 156 return searchbar.querySelector(".searchbar-search-button"); 157 } 158 let searchbar = aDocument.getElementById("searchbar-new"); 159 return searchbar.querySelector(".searchmode-switcher"); 160 }, 161 widgetName: "search-container", 162 }, 163 ], 164 [ 165 "selectedTabIcon", 166 { 167 query: aDocument => { 168 let selectedtab = aDocument.defaultView.gBrowser.selectedTab; 169 let element = selectedtab.iconImage; 170 if (!element || !UITour.isElementVisible(element)) { 171 return null; 172 } 173 return element; 174 }, 175 }, 176 ], 177 [ 178 "urlbar", 179 { 180 query: "#urlbar", 181 widgetName: "urlbar-container", 182 }, 183 ], 184 [ 185 "pageAction-bookmark", 186 { 187 query: aDocument => { 188 // The bookmark's urlbar page action button is pre-defined in the DOM. 189 // It would be hidden if toggled off from the urlbar. 190 let node = aDocument.getElementById("star-button-box"); 191 return node && !node.hidden ? node : null; 192 }, 193 }, 194 ], 195 [ 196 "profilesAppMenuButton", 197 { 198 query: "#appMenu-profiles-button", 199 }, 200 ], 201 ]), 202 203 init() { 204 lazy.log.debug("Initializing UITour"); 205 // Lazy getter is initialized here so it can be replicated any time 206 // in a test. 207 delete this.url; 208 ChromeUtils.defineLazyGetter(this, "url", function () { 209 return Services.urlFormatter.formatURLPref("browser.uitour.url"); 210 }); 211 212 // Clear the availableTargetsCache on widget changes. 213 let listenerMethods = [ 214 "onWidgetAdded", 215 "onWidgetMoved", 216 "onWidgetRemoved", 217 "onWidgetReset", 218 "onAreaReset", 219 ]; 220 lazy.CustomizableUI.addListener( 221 listenerMethods.reduce((listener, method) => { 222 listener[method] = () => this.clearAvailableTargetsCache(); 223 return listener; 224 }, {}) 225 ); 226 227 Services.obs.addObserver(this, lazy.UIState.ON_UPDATE); 228 }, 229 230 getNodeFromDocument(aDocument, aQuery) { 231 let viewCacheTemplate = aDocument.getElementById("appMenu-viewCache"); 232 return ( 233 aDocument.querySelector(aQuery) || 234 viewCacheTemplate.content.querySelector(aQuery) 235 ); 236 }, 237 238 onPageEvent(aEvent, aBrowser) { 239 let browser = aBrowser; 240 let window = browser.ownerGlobal; 241 242 // Does the window have tabs? We need to make sure since windowless browsers do 243 // not have tabs. 244 if (!window.gBrowser) { 245 // When using windowless browsers we don't have a valid |window|. If that's the case, 246 // use the most recent window as a target for UITour functions (see Bug 1111022). 247 window = Services.wm.getMostRecentWindow("navigator:browser"); 248 } 249 250 lazy.log.debug("onPageEvent:", aEvent.detail); 251 252 if (typeof aEvent.detail != "object") { 253 lazy.log.warn("Malformed event - detail not an object"); 254 return false; 255 } 256 257 let action = aEvent.detail.action; 258 if (typeof action != "string" || !action) { 259 lazy.log.warn("Action not defined"); 260 return false; 261 } 262 263 let data = aEvent.detail.data; 264 if (typeof data != "object") { 265 lazy.log.warn("Malformed event - data not an object"); 266 return false; 267 } 268 269 if ( 270 (aEvent.pageVisibilityState == "hidden" || 271 aEvent.pageVisibilityState == "unloaded") && 272 !BACKGROUND_PAGE_ACTIONS_ALLOWED.has(action) 273 ) { 274 lazy.log.warn( 275 "Ignoring disallowed action from a hidden page:", 276 action, 277 aEvent.pageVisibilityState 278 ); 279 return false; 280 } 281 282 switch (action) { 283 case "registerPageID": { 284 break; 285 } 286 287 case "showHighlight": { 288 let targetPromise = this.getTarget(window, data.target); 289 targetPromise 290 .then(target => { 291 if (!target.node) { 292 lazy.log.error( 293 "UITour: Target could not be resolved: " + data.target 294 ); 295 return; 296 } 297 let effect = undefined; 298 if (this.highlightEffects.includes(data.effect)) { 299 effect = data.effect; 300 } 301 this.showHighlight(window, target, effect); 302 }) 303 .catch(lazy.log.error); 304 break; 305 } 306 307 case "hideHighlight": { 308 this.hideHighlight(window); 309 break; 310 } 311 312 case "showInfo": { 313 let targetPromise = this.getTarget(window, data.target, true); 314 targetPromise 315 .then(target => { 316 if (!target.node) { 317 lazy.log.error( 318 "UITour: Target could not be resolved: " + data.target 319 ); 320 return; 321 } 322 323 let iconURL = null; 324 if (typeof data.icon == "string") { 325 iconURL = this.resolveURL(browser, data.icon); 326 } 327 328 let buttons = []; 329 if (Array.isArray(data.buttons) && data.buttons.length) { 330 for (let buttonData of data.buttons) { 331 if ( 332 typeof buttonData == "object" && 333 typeof buttonData.label == "string" && 334 typeof buttonData.callbackID == "string" 335 ) { 336 let callback = buttonData.callbackID; 337 let button = { 338 label: buttonData.label, 339 callback: () => { 340 this.sendPageCallback(browser, callback); 341 }, 342 }; 343 344 if (typeof buttonData.icon == "string") { 345 button.iconURL = this.resolveURL(browser, buttonData.icon); 346 } 347 348 if (typeof buttonData.style == "string") { 349 button.style = buttonData.style; 350 } 351 352 buttons.push(button); 353 354 if (buttons.length == MAX_BUTTONS) { 355 lazy.log.warn( 356 "showInfo: Reached limit of allowed number of buttons" 357 ); 358 break; 359 } 360 } 361 } 362 } 363 364 let infoOptions = {}; 365 if (typeof data.closeButtonCallbackID == "string") { 366 infoOptions.closeButtonCallback = () => { 367 this.sendPageCallback(browser, data.closeButtonCallbackID); 368 }; 369 } 370 if (typeof data.targetCallbackID == "string") { 371 infoOptions.targetCallback = details => { 372 this.sendPageCallback(browser, data.targetCallbackID, details); 373 }; 374 } 375 376 this.showInfo( 377 window, 378 target, 379 data.title, 380 data.text, 381 iconURL, 382 buttons, 383 infoOptions 384 ); 385 }) 386 .catch(lazy.log.error); 387 break; 388 } 389 390 case "hideInfo": { 391 this.hideInfo(window); 392 break; 393 } 394 395 case "showMenu": { 396 this.noautohideMenus.add(data.name); 397 this.showMenu(window, data.name, () => { 398 if (typeof data.showCallbackID == "string") { 399 this.sendPageCallback(browser, data.showCallbackID); 400 } 401 }); 402 break; 403 } 404 405 case "hideMenu": { 406 this.noautohideMenus.delete(data.name); 407 this.hideMenu(window, data.name); 408 break; 409 } 410 411 case "showNewTab": { 412 this.showNewTab(window, browser); 413 break; 414 } 415 416 case "getConfiguration": { 417 if (typeof data.configuration != "string") { 418 lazy.log.warn("getConfiguration: No configuration option specified"); 419 return false; 420 } 421 422 this.getConfiguration( 423 browser, 424 window, 425 data.configuration, 426 data.callbackID 427 ); 428 break; 429 } 430 431 case "setConfiguration": { 432 if (typeof data.configuration != "string") { 433 lazy.log.warn("setConfiguration: No configuration option specified"); 434 return false; 435 } 436 437 this.setConfiguration(window, data.configuration, data.value); 438 break; 439 } 440 441 case "openPreferences": { 442 if (typeof data.pane != "string" && typeof data.pane != "undefined") { 443 lazy.log.warn("openPreferences: Invalid pane specified"); 444 return false; 445 } 446 window.openPreferences(data.pane); 447 break; 448 } 449 450 case "showFirefoxAccounts": { 451 Promise.resolve() 452 .then(() => { 453 return lazy.FxAccounts.canConnectAccount(); 454 }) 455 .then(canConnect => { 456 if (!canConnect) { 457 lazy.log.warn("showFirefoxAccounts: can't currently connect"); 458 return null; 459 } 460 return data.email 461 ? lazy.FxAccounts.config.promiseEmailURI( 462 data.email, 463 data.entrypoint || "uitour" 464 ) 465 : lazy.FxAccounts.config.promiseConnectAccountURI( 466 data.entrypoint || "uitour" 467 ); 468 }) 469 .then(uri => { 470 if (!uri) { 471 return; 472 } 473 const url = new URL(uri); 474 // Call our helper to validate extraURLParams and populate URLSearchParams 475 if (!this._populateURLParams(url, data.extraURLParams)) { 476 lazy.log.warn( 477 "showFirefoxAccounts: invalid campaign args specified" 478 ); 479 return; 480 } 481 // We want to replace the current tab. 482 browser.loadURI(url.URI, { 483 triggeringPrincipal: 484 Services.scriptSecurityManager.createNullPrincipal({}), 485 }); 486 }); 487 break; 488 } 489 490 case "showConnectAnotherDevice": { 491 lazy.FxAccounts.config 492 .promiseConnectDeviceURI(data.entrypoint || "uitour") 493 .then(uri => { 494 const url = new URL(uri); 495 // Call our helper to validate extraURLParams and populate URLSearchParams 496 if (!this._populateURLParams(url, data.extraURLParams)) { 497 lazy.log.warn( 498 "showConnectAnotherDevice: invalid campaign args specified" 499 ); 500 return; 501 } 502 503 // We want to replace the current tab. 504 browser.loadURI(url.URI, { 505 triggeringPrincipal: 506 Services.scriptSecurityManager.createNullPrincipal({}), 507 }); 508 }); 509 break; 510 } 511 512 case "resetFirefox": { 513 // Open a reset profile dialog window. 514 if (lazy.ResetProfile.resetSupported()) { 515 lazy.ResetProfile.openConfirmationDialog(window); 516 } 517 break; 518 } 519 520 case "addNavBarWidget": { 521 // Add a widget to the toolbar 522 let targetPromise = this.getTarget(window, data.name); 523 targetPromise 524 .then(target => { 525 this.addNavBarWidget(target, browser, data.callbackID); 526 }) 527 .catch(lazy.log.error); 528 break; 529 } 530 531 case "setDefaultSearchEngine": { 532 let enginePromise = this.selectSearchEngine(data.identifier); 533 enginePromise.catch(console.error); 534 break; 535 } 536 537 case "setTreatmentTag": { 538 let name = data.name; 539 let value = data.value; 540 Services.prefs.setStringPref("browser.uitour.treatment." + name, value); 541 break; 542 } 543 544 case "getTreatmentTag": { 545 let name = data.name; 546 let value; 547 try { 548 value = Services.prefs.getStringPref( 549 "browser.uitour.treatment." + name 550 ); 551 } catch (ex) {} 552 this.sendPageCallback(browser, data.callbackID, { value }); 553 break; 554 } 555 556 case "setSearchTerm": { 557 let targetPromise = this.getTarget(window, "search"); 558 targetPromise.then(target => { 559 let searchbar = target.node; 560 searchbar.value = data.term; 561 if (!Services.prefs.getBoolPref("browser.search.widget.new")) { 562 searchbar.updateGoButtonVisibility(); 563 } 564 }); 565 break; 566 } 567 568 case "ping": { 569 if (typeof data.callbackID == "string") { 570 this.sendPageCallback(browser, data.callbackID); 571 } 572 break; 573 } 574 575 case "forceShowReaderIcon": { 576 lazy.AboutReaderParent.forceShowReaderIcon(browser); 577 break; 578 } 579 580 case "toggleReaderMode": { 581 let targetPromise = this.getTarget(window, "readerMode-urlBar"); 582 targetPromise.then(target => { 583 lazy.AboutReaderParent.toggleReaderMode({ target: target.node }); 584 }); 585 break; 586 } 587 588 case "closeTab": { 589 // Find the <tabbrowser> element of the <browser> for which the event 590 // was generated originally. If the browser where the UI tour is loaded 591 // is windowless, just ignore the request to close the tab. The request 592 // is also ignored if this is the only tab in the window. 593 let tabBrowser = browser.ownerGlobal.gBrowser; 594 if (tabBrowser && tabBrowser.browsers.length > 1) { 595 tabBrowser.removeTab(tabBrowser.getTabForBrowser(browser)); 596 } 597 break; 598 } 599 600 case "showProtectionReport": { 601 this.showProtectionReport(window, browser); 602 break; 603 } 604 } 605 606 // For performance reasons, only call initForBrowser if we did something 607 // that will require a teardownTourForBrowser call later. 608 // getConfiguration (called from about:home) doesn't require any future 609 // uninitialization. 610 if (action != "getConfiguration") { 611 this.initForBrowser(browser, window); 612 } 613 614 return true; 615 }, 616 617 initForBrowser(aBrowser, window) { 618 let gBrowser = window.gBrowser; 619 620 if (gBrowser) { 621 gBrowser.tabContainer.addEventListener("TabSelect", this); 622 } 623 624 if (!this.tourBrowsersByWindow.has(window)) { 625 this.tourBrowsersByWindow.set(window, new Set()); 626 } 627 this.tourBrowsersByWindow.get(window).add(aBrowser); 628 629 if (!this._initForBrowserObserverAdded) { 630 this._initForBrowserObserverAdded = true; 631 Services.obs.addObserver(this, "message-manager-close"); 632 } 633 window.addEventListener("SSWindowClosing", this); 634 }, 635 636 handleEvent(aEvent) { 637 lazy.log.debug("handleEvent: type =", aEvent.type, "event =", aEvent); 638 switch (aEvent.type) { 639 case "TabSelect": { 640 let window = aEvent.target.ownerGlobal; 641 642 // Teardown the browser of the tab we just switched away from. 643 if (aEvent.detail && aEvent.detail.previousTab) { 644 let previousTab = aEvent.detail.previousTab; 645 let openTourWindows = this.tourBrowsersByWindow.get(window); 646 if (openTourWindows.has(previousTab.linkedBrowser)) { 647 this.teardownTourForBrowser( 648 window, 649 previousTab.linkedBrowser, 650 false 651 ); 652 } 653 } 654 655 break; 656 } 657 658 case "SSWindowClosing": { 659 let window = aEvent.target; 660 this.teardownTourForWindow(window); 661 break; 662 } 663 } 664 }, 665 666 observe(aSubject, aTopic) { 667 lazy.log.debug("observe: aTopic =", aTopic); 668 switch (aTopic) { 669 // The browser message manager is disconnected when the <browser> is 670 // destroyed and we want to teardown at that point. 671 case "message-manager-close": { 672 for (let window of Services.wm.getEnumerator("navigator:browser")) { 673 if (window.closed) { 674 continue; 675 } 676 677 let tourBrowsers = this.tourBrowsersByWindow.get(window); 678 if (!tourBrowsers) { 679 continue; 680 } 681 682 for (let browser of tourBrowsers) { 683 let messageManager = browser.messageManager; 684 if (!messageManager || aSubject == messageManager) { 685 this.teardownTourForBrowser(window, browser, true); 686 } 687 } 688 } 689 break; 690 } 691 case lazy.UIState.ON_UPDATE: { 692 let syncState = lazy.UIState.get(); 693 this.notify("FxA:SignedInStateChange", { status: syncState.status }); 694 break; 695 } 696 } 697 }, 698 699 // Given a string that is a JSONified represenation of an object with 700 // additional "flow_id", "flow_begin_time", "device_id", utm_* URL params 701 // that should be appended, validate and append them to the passed URL object. 702 // Returns true if the params were validated and appended, and false if the 703 // request should be ignored. 704 _populateURLParams(url, extraURLParams) { 705 const FLOW_ID_LENGTH = 64; 706 const FLOW_BEGIN_TIME_LENGTH = 13; 707 708 // We are extra paranoid about what params we allow to be appended. 709 if (typeof extraURLParams == "undefined") { 710 // no params, so it's all good. 711 return true; 712 } 713 if (typeof extraURLParams != "string") { 714 lazy.log.warn("_populateURLParams: extraURLParams is not a string"); 715 return false; 716 } 717 let urlParams; 718 try { 719 if (extraURLParams) { 720 urlParams = JSON.parse(extraURLParams); 721 if (typeof urlParams != "object") { 722 lazy.log.warn( 723 "_populateURLParams: extraURLParams is not a stringified object" 724 ); 725 return false; 726 } 727 } 728 } catch (ex) { 729 lazy.log.warn("_populateURLParams: extraURLParams is not a JSON object"); 730 return false; 731 } 732 if (urlParams) { 733 // Expected to JSON parse the following for FxA flow parameters: 734 // 735 // {String} flow_id - Flow Id, such as '5445b28b8b7ba6cf71e345f8fff4bc59b2a514f78f3e2cc99b696449427fd445' 736 // {Number} flow_begin_time - Flow begin timestamp, such as 1590780440325 737 // {String} device_id - Device Id, such as '7e450f3337d3479b8582ea1c9bb5ba6c' 738 if ( 739 (urlParams.flow_begin_time && 740 urlParams.flow_begin_time.toString().length !== 741 FLOW_BEGIN_TIME_LENGTH) || 742 (urlParams.flow_id && urlParams.flow_id.length !== FLOW_ID_LENGTH) 743 ) { 744 lazy.log.warn( 745 "_populateURLParams: flow parameters are not properly structured" 746 ); 747 return false; 748 } 749 750 // The regex that the name of each param must match - there's no 751 // character restriction on the value - they will be escaped as necessary. 752 let reSimpleString = /^[-_a-zA-Z0-9]*$/; 753 for (let name in urlParams) { 754 let value = urlParams[name]; 755 const validName = 756 name.startsWith("utm_") || 757 name === "entrypoint_experiment" || 758 name === "entrypoint_variation" || 759 name === "flow_begin_time" || 760 name === "flow_id" || 761 name === "device_id"; 762 if ( 763 typeof name != "string" || 764 !validName || 765 !reSimpleString.test(name) 766 ) { 767 lazy.log.warn("_populateURLParams: invalid campaign param specified"); 768 return false; 769 } 770 url.searchParams.append(name, value); 771 } 772 } 773 return true; 774 }, 775 /** 776 * Tear down a tour from a tab e.g. upon switching/closing tabs. 777 */ 778 async teardownTourForBrowser(aWindow, aBrowser, aTourPageClosing = false) { 779 lazy.log.debug( 780 "teardownTourForBrowser: aBrowser = ", 781 aBrowser, 782 aTourPageClosing 783 ); 784 785 let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow); 786 if (aTourPageClosing && openTourBrowsers) { 787 openTourBrowsers.delete(aBrowser); 788 } 789 790 this.hideHighlight(aWindow); 791 this.hideInfo(aWindow); 792 793 await this.removePanelListeners(aWindow); 794 795 this.noautohideMenus.clear(); 796 797 // If there are no more tour tabs left in the window, teardown the tour for the whole window. 798 if (!openTourBrowsers || openTourBrowsers.size == 0) { 799 this.teardownTourForWindow(aWindow); 800 } 801 }, 802 803 /** 804 * Remove the listeners to a panel when tearing the tour down. 805 */ 806 async removePanelListeners(aWindow) { 807 let panels = [ 808 { 809 name: "appMenu", 810 node: aWindow.PanelUI.panel, 811 events: [ 812 ["popuphidden", this.onPanelHidden], 813 ["popuphiding", this.onAppMenuHiding], 814 ["ViewShowing", this.onAppMenuSubviewShowing], 815 ], 816 }, 817 ]; 818 for (let panel of panels) { 819 // Ensure the menu panel is hidden and clean up panel listeners after calling hideMenu. 820 if (panel.node.state != "closed") { 821 await new Promise(resolve => { 822 panel.node.addEventListener("popuphidden", resolve, { once: true }); 823 this.hideMenu(aWindow, panel.name); 824 }); 825 } 826 for (let [name, listener] of panel.events) { 827 panel.node.removeEventListener(name, listener); 828 } 829 } 830 }, 831 832 /** 833 * Tear down all tours for a ChromeWindow. 834 */ 835 teardownTourForWindow(aWindow) { 836 lazy.log.debug("teardownTourForWindow"); 837 aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this); 838 aWindow.removeEventListener("SSWindowClosing", this); 839 840 this.tourBrowsersByWindow.delete(aWindow); 841 }, 842 843 // This function is copied to UITourListener. 844 isSafeScheme(aURI) { 845 let allowedSchemes = new Set(["https", "about"]); 846 if (!allowedSchemes.has(aURI.scheme)) { 847 lazy.log.error("Unsafe scheme:", aURI.scheme); 848 return false; 849 } 850 851 return true; 852 }, 853 854 resolveURL(aBrowser, aURL) { 855 try { 856 let uri = Services.io.newURI(aURL, null, aBrowser.currentURI); 857 858 if (!this.isSafeScheme(uri)) { 859 return null; 860 } 861 862 return uri.spec; 863 } catch (e) {} 864 865 return null; 866 }, 867 868 sendPageCallback(aBrowser, aCallbackID, aData = {}) { 869 let detail = { data: aData, callbackID: aCallbackID }; 870 lazy.log.debug("sendPageCallback", detail); 871 let contextToVisit = aBrowser.browsingContext; 872 let global = contextToVisit.currentWindowGlobal; 873 let actor = global.getActor("UITour"); 874 actor.sendAsyncMessage("UITour:SendPageCallback", detail); 875 }, 876 877 isElementVisible(aElement) { 878 let targetStyle = aElement.ownerGlobal.getComputedStyle(aElement); 879 return ( 880 !aElement.ownerDocument.hidden && 881 targetStyle.display != "none" && 882 targetStyle.visibility == "visible" 883 ); 884 }, 885 886 getTarget(aWindow, aTargetName) { 887 lazy.log.debug("getTarget:", aTargetName); 888 if (typeof aTargetName != "string" || !aTargetName) { 889 lazy.log.warn("getTarget: Invalid target name specified"); 890 return Promise.reject("Invalid target name specified"); 891 } 892 893 let targetObject = this.targets.get(aTargetName); 894 if (!targetObject) { 895 lazy.log.warn( 896 "getTarget: The specified target name is not in the allowed set" 897 ); 898 return Promise.reject( 899 "The specified target name is not in the allowed set" 900 ); 901 } 902 903 return new Promise(resolve => { 904 let targetQuery = targetObject.query; 905 aWindow.PanelUI.ensureReady() 906 .then(() => { 907 let node; 908 if (typeof targetQuery == "function") { 909 try { 910 node = targetQuery(aWindow.document); 911 } catch (ex) { 912 lazy.log.warn("getTarget: Error running target query:", ex); 913 node = null; 914 } 915 } else { 916 node = this.getNodeFromDocument(aWindow.document, targetQuery); 917 } 918 919 resolve({ 920 addTargetListener: targetObject.addTargetListener, 921 infoPanelOffsetX: targetObject.infoPanelOffsetX, 922 infoPanelOffsetY: targetObject.infoPanelOffsetY, 923 infoPanelPosition: targetObject.infoPanelPosition, 924 node, 925 removeTargetListener: targetObject.removeTargetListener, 926 targetName: aTargetName, 927 widgetName: targetObject.widgetName, 928 allowAdd: targetObject.allowAdd, 929 }); 930 }) 931 .catch(lazy.log.error); 932 }); 933 }, 934 935 targetIsInAppMenu(aTarget) { 936 let targetElement = aTarget.node; 937 // Use the widget for filtering if it exists since the target may be the icon inside. 938 if (aTarget.widgetName) { 939 let doc = aTarget.node.ownerGlobal.document; 940 targetElement = 941 doc.getElementById(aTarget.widgetName) || 942 lazy.PanelMultiView.getViewNode(doc, aTarget.widgetName); 943 } 944 945 return targetElement.id.startsWith("appMenu-"); 946 }, 947 948 /** 949 * Called before opening or after closing a highlight or an info tooltip to see if 950 * we need to open or close the menu to see the annotation's anchor. 951 * 952 * @param {ChromeWindow} aWindow the chrome window 953 * @param {bool} aShouldOpen true means we should open the menu, otherwise false 954 * @param {object} aOptions Extra config arguments, example `autohide: true`. 955 */ 956 _setMenuStateForAnnotation(aWindow, aShouldOpen, aOptions = {}) { 957 lazy.log.debug( 958 "_setMenuStateForAnnotation: Menu is expected to be:", 959 aShouldOpen ? "open" : "closed" 960 ); 961 let menu = aWindow.PanelUI.panel; 962 963 // If the panel is in the desired state, we're done. 964 let panelIsOpen = menu.state != "closed"; 965 if (aShouldOpen == panelIsOpen) { 966 lazy.log.debug( 967 "_setMenuStateForAnnotation: Menu already in expected state" 968 ); 969 return Promise.resolve(); 970 } 971 972 // Actually show or hide the menu 973 let promise = null; 974 if (aShouldOpen) { 975 lazy.log.debug("_setMenuStateForAnnotation: Opening the menu"); 976 promise = new Promise(resolve => { 977 this.showMenu(aWindow, "appMenu", resolve, aOptions); 978 }); 979 } else if (!this.noautohideMenus.has("appMenu")) { 980 // If the menu was opened explictly by api user through `Mozilla.UITour.showMenu`, 981 // it should be closed explictly by api user through `Mozilla.UITour.hideMenu`. 982 // So we shouldn't get to here to close it for the highlight/info annotation. 983 lazy.log.debug("_setMenuStateForAnnotation: Closing the menu"); 984 promise = new Promise(resolve => { 985 menu.addEventListener("popuphidden", resolve, { once: true }); 986 this.hideMenu(aWindow, "appMenu"); 987 }); 988 } 989 return promise; 990 }, 991 992 /** 993 * Ensure the target's visibility and the open/close states of menus for the target. 994 * 995 * @param {ChromeWindow} aChromeWindow The chrome window 996 * @param {object} aTarget The target on which we show highlight or show info. 997 * @param {object} aOptions Extra config arguments, example `autohide: true`. 998 */ 999 async _ensureTarget(aChromeWindow, aTarget, aOptions = {}) { 1000 let shouldOpenAppMenu = false; 1001 if (this.targetIsInAppMenu(aTarget)) { 1002 shouldOpenAppMenu = true; 1003 } 1004 1005 // Prevent showing a panel at an undefined position, but when it's tucked 1006 // away inside a panel, we skip this check. 1007 if ( 1008 !aTarget.node.closest("panelview") && 1009 !this.isElementVisible(aTarget.node) 1010 ) { 1011 return Promise.reject( 1012 `_ensureTarget: Reject the ${ 1013 aTarget.name || aTarget.targetName 1014 } target since it isn't visible.` 1015 ); 1016 } 1017 1018 let menuClosePromises = []; 1019 if (!shouldOpenAppMenu) { 1020 menuClosePromises.push( 1021 this._setMenuStateForAnnotation(aChromeWindow, false) 1022 ); 1023 } 1024 1025 let promise = Promise.all(menuClosePromises); 1026 await promise; 1027 if (shouldOpenAppMenu) { 1028 promise = this._setMenuStateForAnnotation(aChromeWindow, true, aOptions); 1029 } 1030 return promise; 1031 }, 1032 1033 /** 1034 * The node to which a highlight or notification(-popup) is anchored is sometimes 1035 * obscured because it may be inside an overflow menu. This function should figure 1036 * that out and offer the overflow chevron as an alternative. 1037 * 1038 * @param {ChromeWindow} aChromeWindow The chrome window 1039 * @param {object} aTarget The target object whose node is supposed to be the anchor 1040 * @type {Node} 1041 */ 1042 async _correctAnchor(aChromeWindow, aTarget) { 1043 // PanelMultiView's like the AppMenu might shuffle the DOM, which might result 1044 // in our anchor being invalidated if it was anonymous content (since the XBL 1045 // binding it belonged to got destroyed). We work around this by re-querying for 1046 // the node and stuffing it into the old anchor structure. 1047 let refreshedTarget = await this.getTarget( 1048 aChromeWindow, 1049 aTarget.targetName 1050 ); 1051 let node = (aTarget.node = refreshedTarget.node); 1052 // If the target is in the overflow panel, just return the overflow button. 1053 if (node.closest("#widget-overflow-mainView")) { 1054 return lazy.CustomizableUI.getWidget(node.id).forWindow(aChromeWindow) 1055 .anchor; 1056 } 1057 return node; 1058 }, 1059 1060 /** 1061 * @param {ChromeWindow} aChromeWindow 1062 * The chrome window that the highlight is in. Necessary since some targets 1063 * are in a sub-frame so the defaultView is not the same as the chrome 1064 * window. 1065 * @param {DOMElement} aTarget 1066 * The element to highlight. 1067 * @param {string} [aEffect] 1068 * The effect to use from UITour.highlightEffects or "none". 1069 * @param {object} [aOptions] 1070 * Extra config arguments, example `autohide: true`. 1071 * @see UITour.highlightEffects 1072 */ 1073 async showHighlight(aChromeWindow, aTarget, aEffect = "none", aOptions = {}) { 1074 let showHighlightElement = aAnchorEl => { 1075 let highlighter = this.getHighlightAndMaybeCreate(aChromeWindow.document); 1076 1077 let effect = aEffect; 1078 if (effect == "random") { 1079 // Exclude "random" from the randomly selected effects. 1080 let randomEffect = 1081 1 + Math.floor(Math.random() * (this.highlightEffects.length - 1)); 1082 if (randomEffect == this.highlightEffects.length) { 1083 randomEffect--; 1084 } // On the order of 1 in 2^62 chance of this happening. 1085 effect = this.highlightEffects[randomEffect]; 1086 } 1087 // Toggle the effect attribute to "none" and flush layout before setting it so the effect plays. 1088 highlighter.setAttribute("active", "none"); 1089 aChromeWindow.getComputedStyle(highlighter).animationName; 1090 highlighter.setAttribute("active", effect); 1091 highlighter.parentElement.setAttribute("targetName", aTarget.targetName); 1092 highlighter.parentElement.hidden = false; 1093 1094 let highlightAnchor = aAnchorEl; 1095 let targetRect = highlightAnchor.getBoundingClientRect(); 1096 let highlightHeight = targetRect.height; 1097 let highlightWidth = targetRect.width; 1098 1099 if (this.targetIsInAppMenu(aTarget)) { 1100 highlighter.classList.remove("rounded-highlight"); 1101 } else { 1102 highlighter.classList.add("rounded-highlight"); 1103 } 1104 if ( 1105 highlightAnchor.classList.contains("toolbarbutton-1") && 1106 highlightAnchor.getAttribute("cui-areatype") === "toolbar" && 1107 highlightAnchor.getAttribute("overflowedItem") !== "true" 1108 ) { 1109 // A toolbar button in navbar has its clickable area an 1110 // inner-contained square while the button component itself is a tall 1111 // rectangle. We adjust the highlight area to a square as well. 1112 highlightHeight = highlightWidth; 1113 } 1114 1115 highlighter.style.height = highlightHeight + "px"; 1116 highlighter.style.width = highlightWidth + "px"; 1117 1118 // Close a previous highlight so we can relocate the panel. 1119 if ( 1120 highlighter.parentElement.state == "showing" || 1121 highlighter.parentElement.state == "open" 1122 ) { 1123 lazy.log.debug("showHighlight: Closing previous highlight first"); 1124 highlighter.parentElement.hidePopup(); 1125 } 1126 /* The "overlap" position anchors from the top-left but we want to centre highlights at their 1127 minimum size. */ 1128 let highlightWindow = aChromeWindow; 1129 let highlightStyle = highlightWindow.getComputedStyle(highlighter); 1130 let highlightHeightWithMin = Math.max( 1131 highlightHeight, 1132 parseFloat(highlightStyle.minHeight) 1133 ); 1134 let highlightWidthWithMin = Math.max( 1135 highlightWidth, 1136 parseFloat(highlightStyle.minWidth) 1137 ); 1138 let offsetX = (targetRect.width - highlightWidthWithMin) / 2; 1139 let offsetY = (targetRect.height - highlightHeightWithMin) / 2; 1140 this._addAnnotationPanelMutationObserver(highlighter.parentElement); 1141 highlighter.parentElement.openPopup( 1142 highlightAnchor, 1143 "overlap", 1144 offsetX, 1145 offsetY 1146 ); 1147 }; 1148 1149 try { 1150 await this._ensureTarget(aChromeWindow, aTarget, aOptions); 1151 let anchorEl = await this._correctAnchor(aChromeWindow, aTarget); 1152 showHighlightElement(anchorEl); 1153 } catch (e) { 1154 lazy.log.warn(e); 1155 } 1156 }, 1157 1158 _hideHighlightElement(aWindow) { 1159 let highlighter = this.getHighlightAndMaybeCreate(aWindow.document); 1160 this._removeAnnotationPanelMutationObserver(highlighter.parentElement); 1161 highlighter.parentElement.hidePopup(); 1162 highlighter.removeAttribute("active"); 1163 }, 1164 1165 hideHighlight(aWindow) { 1166 this._hideHighlightElement(aWindow); 1167 this._setMenuStateForAnnotation(aWindow, false); 1168 }, 1169 1170 /** 1171 * Show an info panel. 1172 * 1173 * @param {ChromeWindow} aChromeWindow 1174 * @param {Node} aAnchor 1175 * @param {string} [aTitle=""] 1176 * @param {string} [aDescription=""] 1177 * @param {string} [aIconURL=""] 1178 * @param {object[]} [aButtons=[]] 1179 * @param {object} [aOptions={}] 1180 * @param {string} [aOptions.closeButtonCallback] 1181 * @param {string} [aOptions.targetCallback] 1182 */ 1183 async showInfo( 1184 aChromeWindow, 1185 aAnchor, 1186 aTitle = "", 1187 aDescription = "", 1188 aIconURL = "", 1189 aButtons = [], 1190 aOptions = {} 1191 ) { 1192 let showInfoElement = aAnchorEl => { 1193 aAnchorEl.focus(); 1194 1195 let document = aChromeWindow.document; 1196 let tooltip = this.getTooltipAndMaybeCreate(document); 1197 let tooltipTitle = document.getElementById("UITourTooltipTitle"); 1198 let tooltipDesc = document.getElementById("UITourTooltipDescription"); 1199 let tooltipIcon = document.getElementById("UITourTooltipIcon"); 1200 let tooltipButtons = document.getElementById("UITourTooltipButtons"); 1201 1202 if (tooltip.state == "showing" || tooltip.state == "open") { 1203 tooltip.hidePopup(); 1204 } 1205 1206 tooltipTitle.textContent = aTitle || ""; 1207 tooltipDesc.textContent = aDescription || ""; 1208 tooltipIcon.src = aIconURL || ""; 1209 tooltipIcon.hidden = !aIconURL; 1210 1211 while (tooltipButtons.firstChild) { 1212 tooltipButtons.firstChild.remove(); 1213 } 1214 1215 for (let button of aButtons) { 1216 let isButton = button.style != "text"; 1217 let el = document.createXULElement(isButton ? "button" : "label"); 1218 el.setAttribute(isButton ? "label" : "value", button.label); 1219 1220 if (isButton) { 1221 if (button.iconURL) { 1222 el.setAttribute("image", button.iconURL); 1223 } 1224 1225 if (button.style == "link") { 1226 el.setAttribute("class", "button-link"); 1227 } 1228 1229 if (button.style == "primary") { 1230 el.setAttribute("class", "button-primary"); 1231 } 1232 1233 // Don't close the popup or call the callback for style=text as they 1234 // aren't links/buttons. 1235 let callback = button.callback; 1236 el.addEventListener("command", event => { 1237 tooltip.hidePopup(); 1238 callback(event); 1239 }); 1240 } 1241 1242 tooltipButtons.appendChild(el); 1243 } 1244 1245 tooltipButtons.hidden = !aButtons.length; 1246 1247 let tooltipClose = document.getElementById("UITourTooltipClose"); 1248 let closeButtonCallback = () => { 1249 this.hideInfo(document.defaultView); 1250 if (aOptions && aOptions.closeButtonCallback) { 1251 aOptions.closeButtonCallback(); 1252 } 1253 }; 1254 tooltipClose.addEventListener("command", closeButtonCallback); 1255 1256 let targetCallback = event => { 1257 let details = { 1258 target: aAnchor.targetName, 1259 type: event.type, 1260 }; 1261 aOptions.targetCallback(details); 1262 }; 1263 if (aOptions.targetCallback && aAnchor.addTargetListener) { 1264 aAnchor.addTargetListener(document, targetCallback); 1265 } 1266 1267 tooltip.addEventListener( 1268 "popuphiding", 1269 function () { 1270 tooltipClose.removeEventListener("command", closeButtonCallback); 1271 if (aOptions.targetCallback && aAnchor.removeTargetListener) { 1272 aAnchor.removeTargetListener(document, targetCallback); 1273 } 1274 }, 1275 { once: true } 1276 ); 1277 1278 tooltip.setAttribute("targetName", aAnchor.targetName); 1279 1280 let alignment = "bottomright topright"; 1281 if (aAnchor.infoPanelPosition) { 1282 alignment = aAnchor.infoPanelPosition; 1283 } 1284 1285 let { infoPanelOffsetX: xOffset, infoPanelOffsetY: yOffset } = aAnchor; 1286 1287 this._addAnnotationPanelMutationObserver(tooltip); 1288 tooltip.openPopup(aAnchorEl, alignment, xOffset || 0, yOffset || 0); 1289 if (tooltip.state == "closed") { 1290 document.defaultView.addEventListener( 1291 "endmodalstate", 1292 function () { 1293 tooltip.openPopup(aAnchorEl, alignment); 1294 }, 1295 { once: true } 1296 ); 1297 } 1298 }; 1299 1300 try { 1301 await this._ensureTarget(aChromeWindow, aAnchor); 1302 let anchorEl = await this._correctAnchor(aChromeWindow, aAnchor); 1303 showInfoElement(anchorEl); 1304 } catch (e) { 1305 lazy.log.warn(e); 1306 } 1307 }, 1308 1309 getHighlightContainerAndMaybeCreate(document) { 1310 let highlightContainer = document.getElementById( 1311 "UITourHighlightContainer" 1312 ); 1313 if (!highlightContainer) { 1314 let wrapper = document.getElementById("UITourHighlightTemplate"); 1315 wrapper.replaceWith(wrapper.content); 1316 highlightContainer = document.getElementById("UITourHighlightContainer"); 1317 } 1318 1319 return highlightContainer; 1320 }, 1321 1322 getTooltipAndMaybeCreate(document) { 1323 let tooltip = document.getElementById("UITourTooltip"); 1324 if (!tooltip) { 1325 let wrapper = document.getElementById("UITourTooltipTemplate"); 1326 wrapper.replaceWith(wrapper.content); 1327 tooltip = document.getElementById("UITourTooltip"); 1328 } 1329 return tooltip; 1330 }, 1331 1332 getHighlightAndMaybeCreate(document) { 1333 let highlight = document.getElementById("UITourHighlight"); 1334 if (!highlight) { 1335 let wrapper = document.getElementById("UITourHighlightTemplate"); 1336 wrapper.replaceWith(wrapper.content); 1337 highlight = document.getElementById("UITourHighlight"); 1338 } 1339 return highlight; 1340 }, 1341 1342 isInfoOnTarget(aChromeWindow, aTargetName) { 1343 let document = aChromeWindow.document; 1344 let tooltip = this.getTooltipAndMaybeCreate(document); 1345 return ( 1346 tooltip.getAttribute("targetName") == aTargetName && 1347 tooltip.state != "closed" 1348 ); 1349 }, 1350 1351 _hideInfoElement(aWindow) { 1352 let document = aWindow.document; 1353 let tooltip = this.getTooltipAndMaybeCreate(document); 1354 this._removeAnnotationPanelMutationObserver(tooltip); 1355 tooltip.hidePopup(); 1356 let tooltipButtons = document.getElementById("UITourTooltipButtons"); 1357 while (tooltipButtons.firstChild) { 1358 tooltipButtons.firstChild.remove(); 1359 } 1360 }, 1361 1362 hideInfo(aWindow) { 1363 this._hideInfoElement(aWindow); 1364 this._setMenuStateForAnnotation(aWindow, false); 1365 }, 1366 1367 showMenu(aWindow, aMenuName, aOpenCallback = null, aOptions = {}) { 1368 lazy.log.debug("showMenu:", aMenuName); 1369 function openMenuButton(aMenuBtn) { 1370 if (!aMenuBtn || !aMenuBtn.hasMenu() || aMenuBtn.open) { 1371 if (aOpenCallback) { 1372 aOpenCallback(); 1373 } 1374 return; 1375 } 1376 if (aOpenCallback) { 1377 aMenuBtn.addEventListener("popupshown", aOpenCallback, { once: true }); 1378 } 1379 aMenuBtn.openMenu(true); 1380 } 1381 1382 if (aMenuName == "appMenu") { 1383 let menu = { 1384 onPanelHidden: this.onPanelHidden, 1385 }; 1386 menu.node = aWindow.PanelUI.panel; 1387 menu.onPopupHiding = this.onAppMenuHiding; 1388 menu.onViewShowing = this.onAppMenuSubviewShowing; 1389 menu.show = () => aWindow.PanelUI.show(); 1390 1391 if (!aOptions.autohide) { 1392 menu.node.setAttribute("noautohide", "true"); 1393 } 1394 // If the popup is already opened, don't recreate the widget as it may cause a flicker. 1395 if (menu.node.state != "open") { 1396 this.recreatePopup(menu.node); 1397 } 1398 if (aOpenCallback) { 1399 menu.node.addEventListener("popupshown", aOpenCallback, { once: true }); 1400 } 1401 menu.node.addEventListener("popuphidden", menu.onPanelHidden); 1402 menu.node.addEventListener("popuphiding", menu.onPopupHiding); 1403 menu.node.addEventListener("ViewShowing", menu.onViewShowing); 1404 menu.show(); 1405 } else if (aMenuName == "bookmarks") { 1406 let menuBtn = aWindow.document.getElementById("bookmarks-menu-button"); 1407 openMenuButton(menuBtn); 1408 } else if (aMenuName == "urlbar") { 1409 let urlbar = aWindow.gURLBar; 1410 if (aOpenCallback) { 1411 urlbar.panel.addEventListener("popupshown", aOpenCallback, { 1412 once: true, 1413 }); 1414 } 1415 urlbar.focus(); 1416 // To demonstrate the ability of searching, we type "Firefox" in advance 1417 // for URLBar's dropdown. To limit the search results on browser-related 1418 // items, we use "Firefox" hard-coded rather than l10n brandShortName 1419 // entity to avoid unrelated or unpredicted results for, like, Nightly 1420 // or translated entites. 1421 const SEARCH_STRING = "Firefox"; 1422 urlbar.value = SEARCH_STRING; 1423 urlbar.select(); 1424 urlbar.startQuery({ 1425 searchString: SEARCH_STRING, 1426 allowAutofill: false, 1427 }); 1428 } 1429 }, 1430 1431 hideMenu(aWindow, aMenuName) { 1432 lazy.log.debug("hideMenu:", aMenuName); 1433 function closeMenuButton(aMenuBtn) { 1434 if (aMenuBtn && aMenuBtn.hasMenu()) { 1435 aMenuBtn.openMenu(false); 1436 } 1437 } 1438 1439 if (aMenuName == "appMenu") { 1440 aWindow.PanelUI.hide(); 1441 } else if (aMenuName == "bookmarks") { 1442 let menuBtn = aWindow.document.getElementById("bookmarks-menu-button"); 1443 closeMenuButton(menuBtn); 1444 } else if (aMenuName == "urlbar") { 1445 aWindow.gURLBar.view.close(); 1446 } 1447 }, 1448 1449 showNewTab(aWindow, aBrowser) { 1450 aWindow.gURLBar.focus(); 1451 let url = "about:newtab"; 1452 aWindow.openLinkIn(url, "current", { 1453 targetBrowser: aBrowser, 1454 triggeringPrincipal: 1455 Services.scriptSecurityManager.createContentPrincipal( 1456 Services.io.newURI(url), 1457 {} 1458 ), 1459 }); 1460 }, 1461 1462 showProtectionReport(aWindow, aBrowser) { 1463 let url = "about:protections"; 1464 aWindow.openLinkIn(url, "current", { 1465 targetBrowser: aBrowser, 1466 triggeringPrincipal: 1467 Services.scriptSecurityManager.createContentPrincipal( 1468 Services.io.newURI(url), 1469 {} 1470 ), 1471 }); 1472 }, 1473 1474 _hideAnnotationsForPanel(aEvent, aShouldClosePanel, aTargetPositionCallback) { 1475 let win = aEvent.target.ownerGlobal; 1476 let hideHighlightMethod = null; 1477 let hideInfoMethod = null; 1478 if (aShouldClosePanel) { 1479 hideHighlightMethod = aWin => this.hideHighlight(aWin); 1480 hideInfoMethod = aWin => this.hideInfo(aWin); 1481 } else { 1482 // Don't have to close panel, let's only hide annotation elements 1483 hideHighlightMethod = aWin => this._hideHighlightElement(aWin); 1484 hideInfoMethod = aWin => this._hideInfoElement(aWin); 1485 } 1486 let annotationElements = new Map([ 1487 // [annotationElement (panel), method to hide the annotation] 1488 [ 1489 this.getHighlightContainerAndMaybeCreate(win.document), 1490 hideHighlightMethod, 1491 ], 1492 [this.getTooltipAndMaybeCreate(win.document), hideInfoMethod], 1493 ]); 1494 annotationElements.forEach((hideMethod, annotationElement) => { 1495 if (annotationElement.state != "closed") { 1496 let targetName = annotationElement.getAttribute("targetName"); 1497 UITour.getTarget(win, targetName) 1498 .then(aTarget => { 1499 // Since getTarget is async, we need to make sure that the target hasn't 1500 // changed since it may have just moved to somewhere outside of the app menu. 1501 if ( 1502 annotationElement.getAttribute("targetName") != 1503 aTarget.targetName || 1504 annotationElement.state == "closed" || 1505 !aTargetPositionCallback(aTarget) 1506 ) { 1507 return; 1508 } 1509 hideMethod(win); 1510 }) 1511 .catch(lazy.log.error); 1512 } 1513 }); 1514 }, 1515 1516 onAppMenuHiding(aEvent) { 1517 UITour._hideAnnotationsForPanel(aEvent, true, UITour.targetIsInAppMenu); 1518 }, 1519 1520 onAppMenuSubviewShowing(aEvent) { 1521 UITour._hideAnnotationsForPanel(aEvent, false, UITour.targetIsInAppMenu); 1522 }, 1523 1524 onPanelHidden(aEvent) { 1525 aEvent.target.removeAttribute("noautohide"); 1526 UITour.recreatePopup(aEvent.target); 1527 UITour.clearAvailableTargetsCache(); 1528 }, 1529 1530 recreatePopup(aPanel) { 1531 // After changing popup attributes that relate to how the native widget is created 1532 // (e.g. @noautohide) we need to re-create the frame/widget for it to take effect. 1533 if (aPanel.hidden) { 1534 // If the panel is already hidden, we don't need to recreate it but flush 1535 // in case someone just hid it. 1536 aPanel.clientWidth; // flush 1537 return; 1538 } 1539 aPanel.hidden = true; 1540 aPanel.clientWidth; // flush 1541 aPanel.hidden = false; 1542 }, 1543 1544 getConfiguration(aBrowser, aWindow, aConfiguration, aCallbackID) { 1545 switch (aConfiguration) { 1546 case "appinfo": 1547 this.getAppInfo(aBrowser, aWindow, aCallbackID); 1548 break; 1549 case "availableTargets": 1550 this.getAvailableTargets(aBrowser, aWindow, aCallbackID); 1551 break; 1552 case "search": 1553 case "selectedSearchEngine": 1554 Services.search 1555 .getVisibleEngines() 1556 .then(engines => { 1557 let { defaultEngine } = Services.search; 1558 this.sendPageCallback(aBrowser, aCallbackID, { 1559 searchEngineIdentifier: defaultEngine.isAppProvided 1560 ? defaultEngine.id 1561 : null, 1562 engines: engines 1563 .filter(engine => engine.isAppProvided) 1564 .map(engine => TARGET_SEARCHENGINE_PREFIX + engine.id), 1565 }); 1566 }) 1567 .catch(() => { 1568 this.sendPageCallback(aBrowser, aCallbackID, { 1569 engines: [], 1570 searchEngineIdentifier: "", 1571 }); 1572 }); 1573 break; 1574 case "fxa": 1575 this.getFxA(aBrowser, aCallbackID); 1576 break; 1577 case "fxaConnections": 1578 this.getFxAConnections(aBrowser, aCallbackID); 1579 break; 1580 1581 // NOTE: 'sync' is deprecated and should be removed in Firefox 73 (because 1582 // by then, all consumers will have upgraded to use 'fxa' in that version 1583 // and later.) 1584 case "sync": 1585 this.sendPageCallback(aBrowser, aCallbackID, { 1586 setup: Services.prefs.prefHasUserValue("services.sync.username"), 1587 desktopDevices: Services.prefs.getIntPref( 1588 "services.sync.clients.devices.desktop", 1589 0 1590 ), 1591 mobileDevices: Services.prefs.getIntPref( 1592 "services.sync.clients.devices.mobile", 1593 0 1594 ), 1595 totalDevices: Services.prefs.getIntPref( 1596 "services.sync.numClients", 1597 0 1598 ), 1599 }); 1600 break; 1601 case "canReset": 1602 this.sendPageCallback( 1603 aBrowser, 1604 aCallbackID, 1605 lazy.ResetProfile.resetSupported() 1606 ); 1607 break; 1608 default: 1609 lazy.log.error( 1610 "getConfiguration: Unknown configuration requested: " + aConfiguration 1611 ); 1612 break; 1613 } 1614 }, 1615 1616 async setConfiguration(aWindow, aConfiguration, _aValue) { 1617 switch (aConfiguration) { 1618 case "defaultBrowser": 1619 // Ignore aValue in this case because the default browser can only 1620 // be set, not unset. 1621 try { 1622 let shell = aWindow.getShellService(); 1623 if (shell) { 1624 await shell.setDefaultBrowser(false); 1625 } 1626 } catch (e) {} 1627 break; 1628 default: 1629 lazy.log.error( 1630 "setConfiguration: Unknown configuration requested: " + aConfiguration 1631 ); 1632 break; 1633 } 1634 }, 1635 1636 // Get data about the local FxA account. This should be a low-latency request 1637 // - everything returned here can be obtained locally without hitting any 1638 // remote servers. See also `getFxAConnections()` 1639 getFxA(aBrowser, aCallbackID) { 1640 (async () => { 1641 let setup = !!(await lazy.fxAccounts.getSignedInUser()); 1642 let result = { setup }; 1643 if (!setup) { 1644 this.sendPageCallback(aBrowser, aCallbackID, result); 1645 return; 1646 } 1647 // We are signed in so need to build a richer result. 1648 // Each of the "browser services" - currently only "sync" is supported 1649 result.browserServices = {}; 1650 let hasSync = Services.prefs.prefHasUserValue("services.sync.username"); 1651 if (hasSync) { 1652 result.browserServices.sync = { 1653 // We always include 'setup' for b/w compatibility. 1654 setup: true, 1655 desktopDevices: Services.prefs.getIntPref( 1656 "services.sync.clients.devices.desktop", 1657 0 1658 ), 1659 mobileDevices: Services.prefs.getIntPref( 1660 "services.sync.clients.devices.mobile", 1661 0 1662 ), 1663 totalDevices: Services.prefs.getIntPref( 1664 "services.sync.numClients", 1665 0 1666 ), 1667 }; 1668 } 1669 // And the account state. 1670 result.accountStateOK = await lazy.fxAccounts.hasLocalSession(); 1671 this.sendPageCallback(aBrowser, aCallbackID, result); 1672 })().catch(err => { 1673 lazy.log.error(err); 1674 this.sendPageCallback(aBrowser, aCallbackID, {}); 1675 }); 1676 }, 1677 1678 // Get data about the FxA account "connections" (ie, other devices, other 1679 // apps, etc. Note that this is likely to be a high-latency request - we will 1680 // usually hit the FxA servers to obtain this info. 1681 getFxAConnections(aBrowser, aCallbackID) { 1682 (async () => { 1683 let setup = !!(await lazy.fxAccounts.getSignedInUser()); 1684 let result = { setup }; 1685 if (!setup) { 1686 this.sendPageCallback(aBrowser, aCallbackID, result); 1687 return; 1688 } 1689 // We are signed in so need to build a richer result. 1690 let devices = lazy.fxAccounts.device.recentDeviceList; 1691 // A recent device list is fine, but if we don't even have that we should 1692 // wait for it to be fetched. 1693 if (!devices) { 1694 try { 1695 await lazy.fxAccounts.device.refreshDeviceList(); 1696 } catch (ex) { 1697 lazy.log.warn("failed to fetch device list", ex); 1698 } 1699 devices = lazy.fxAccounts.device.recentDeviceList; 1700 } 1701 if (devices) { 1702 // A falsey `devices` should be impossible, so we omit `devices` from 1703 // the result object so the consuming page can try to differentiate 1704 // between "no additional devices" and "something's wrong" 1705 result.numOtherDevices = Math.max(0, devices.length - 1); 1706 result.numDevicesByType = devices 1707 .filter(d => !d.isCurrentDevice) 1708 .reduce((accum, d) => { 1709 let type = d.type || "unknown"; 1710 accum[type] = (accum[type] || 0) + 1; 1711 return accum; 1712 }, {}); 1713 } 1714 1715 try { 1716 // Each of the "account services", which we turn into a map keyed by ID. 1717 let attachedClients = await lazy.fxAccounts.listAttachedOAuthClients(); 1718 result.accountServices = attachedClients 1719 .filter(c => !!c.id) 1720 .reduce((accum, c) => { 1721 accum[c.id] = { 1722 id: c.id, 1723 lastAccessedWeeksAgo: c.lastAccessedDaysAgo 1724 ? Math.floor(c.lastAccessedDaysAgo / 7) 1725 : null, 1726 }; 1727 return accum; 1728 }, {}); 1729 } catch (ex) { 1730 lazy.log.warn("Failed to build the attached clients list", ex); 1731 } 1732 this.sendPageCallback(aBrowser, aCallbackID, result); 1733 })().catch(err => { 1734 lazy.log.error(err); 1735 this.sendPageCallback(aBrowser, aCallbackID, {}); 1736 }); 1737 }, 1738 1739 getAppInfo(aBrowser, aWindow, aCallbackID) { 1740 (async () => { 1741 let appinfo = { version: Services.appinfo.version }; 1742 1743 // Identifier of the partner repack, as stored in preference "distribution.id" 1744 // and included in Firefox and other update pings. Note this is not the same as 1745 // Services.appinfo.distributionID (value of MOZ_DISTRIBUTION_ID is set at build time). 1746 let distribution = Services.prefs 1747 .getDefaultBranch("distribution.") 1748 .getCharPref("id", "default"); 1749 appinfo.distribution = distribution; 1750 1751 // Update channel, in a way that preserves 'beta' for RC beta builds: 1752 appinfo.defaultUpdateChannel = lazy.UpdateUtils.getUpdateChannel( 1753 false /* no partner ID */ 1754 ); 1755 1756 let isDefaultBrowser = null; 1757 try { 1758 let shell = aWindow.getShellService(); 1759 if (shell) { 1760 isDefaultBrowser = shell.isDefaultBrowser(false); 1761 } 1762 } catch (e) {} 1763 appinfo.defaultBrowser = isDefaultBrowser; 1764 1765 let canSetDefaultBrowserInBackground = true; 1766 if (AppConstants.platform == "win" || AppConstants.platform == "macosx") { 1767 canSetDefaultBrowserInBackground = false; 1768 } else if (AppConstants.platform == "linux") { 1769 // The ShellService may not exist on some versions of Linux. 1770 try { 1771 aWindow.getShellService(); 1772 } catch (e) { 1773 canSetDefaultBrowserInBackground = null; 1774 } 1775 } 1776 1777 appinfo.canSetDefaultBrowserInBackground = 1778 canSetDefaultBrowserInBackground; 1779 1780 // Expose Profile creation and last reset dates in weeks. 1781 const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; 1782 let profileAge = await lazy.ProfileAge(); 1783 let createdDate = await profileAge.created; 1784 let resetDate = await profileAge.reset; 1785 let createdWeeksAgo = Math.floor((Date.now() - createdDate) / ONE_WEEK); 1786 let resetWeeksAgo = null; 1787 if (resetDate) { 1788 resetWeeksAgo = Math.floor((Date.now() - resetDate) / ONE_WEEK); 1789 } 1790 appinfo.profileCreatedWeeksAgo = createdWeeksAgo; 1791 appinfo.profileResetWeeksAgo = resetWeeksAgo; 1792 1793 this.sendPageCallback(aBrowser, aCallbackID, appinfo); 1794 })().catch(err => { 1795 lazy.log.error(err); 1796 this.sendPageCallback(aBrowser, aCallbackID, {}); 1797 }); 1798 }, 1799 1800 getAvailableTargets(aBrowser, aChromeWindow, aCallbackID) { 1801 (async () => { 1802 let window = aChromeWindow; 1803 let data = this.availableTargetsCache.get(window); 1804 if (data) { 1805 lazy.log.debug( 1806 "getAvailableTargets: Using cached targets list", 1807 data.targets.join(",") 1808 ); 1809 this.sendPageCallback(aBrowser, aCallbackID, data); 1810 return; 1811 } 1812 1813 let promises = []; 1814 for (let targetName of this.targets.keys()) { 1815 promises.push(this.getTarget(window, targetName)); 1816 } 1817 let targetObjects = await Promise.all(promises); 1818 1819 let targetNames = []; 1820 for (let targetObject of targetObjects) { 1821 if (targetObject.node) { 1822 targetNames.push(targetObject.targetName); 1823 } 1824 } 1825 1826 data = { 1827 targets: targetNames, 1828 }; 1829 this.availableTargetsCache.set(window, data); 1830 this.sendPageCallback(aBrowser, aCallbackID, data); 1831 })().catch(err => { 1832 lazy.log.error(err); 1833 this.sendPageCallback(aBrowser, aCallbackID, { 1834 targets: [], 1835 }); 1836 }); 1837 }, 1838 1839 addNavBarWidget(aTarget, aBrowser, aCallbackID) { 1840 if (aTarget.node) { 1841 lazy.log.error( 1842 "addNavBarWidget: can't add a widget already present:", 1843 aTarget 1844 ); 1845 return; 1846 } 1847 if (!aTarget.allowAdd) { 1848 lazy.log.error( 1849 "addNavBarWidget: not allowed to add this widget:", 1850 aTarget 1851 ); 1852 return; 1853 } 1854 if (!aTarget.widgetName) { 1855 lazy.log.error( 1856 "addNavBarWidget: can't add a widget without a widgetName property:", 1857 aTarget 1858 ); 1859 return; 1860 } 1861 1862 lazy.CustomizableUI.addWidgetToArea( 1863 aTarget.widgetName, 1864 lazy.CustomizableUI.AREA_NAVBAR 1865 ); 1866 lazy.BrowserUsageTelemetry.recordWidgetChange( 1867 aTarget.widgetName, 1868 lazy.CustomizableUI.AREA_NAVBAR, 1869 "uitour" 1870 ); 1871 this.sendPageCallback(aBrowser, aCallbackID); 1872 }, 1873 1874 _addAnnotationPanelMutationObserver(aPanelEl) { 1875 if (AppConstants.platform == "linux") { 1876 let observer = this._annotationPanelMutationObservers.get(aPanelEl); 1877 if (observer) { 1878 return; 1879 } 1880 let win = aPanelEl.ownerGlobal; 1881 observer = new win.MutationObserver(this._annotationMutationCallback); 1882 this._annotationPanelMutationObservers.set(aPanelEl, observer); 1883 let observerOptions = { 1884 attributeFilter: ["height", "width"], 1885 attributes: true, 1886 }; 1887 observer.observe(aPanelEl, observerOptions); 1888 } 1889 }, 1890 1891 _removeAnnotationPanelMutationObserver(aPanelEl) { 1892 if (AppConstants.platform == "linux") { 1893 let observer = this._annotationPanelMutationObservers.get(aPanelEl); 1894 if (observer) { 1895 observer.disconnect(); 1896 this._annotationPanelMutationObservers.delete(aPanelEl); 1897 } 1898 } 1899 }, 1900 1901 /** 1902 * Workaround for Ubuntu panel craziness in bug 970788 where incorrect sizes get passed to 1903 * nsXULPopupManager::PopupResized and lead to incorrect width and height attributes getting 1904 * set on the panel. 1905 */ 1906 _annotationMutationCallback(aMutations) { 1907 for (let mutation of aMutations) { 1908 // Remove both attributes at once and ignore remaining mutations to be proccessed. 1909 mutation.target.removeAttribute("width"); 1910 mutation.target.removeAttribute("height"); 1911 return; 1912 } 1913 }, 1914 1915 async selectSearchEngine(id) { 1916 let engine = Services.search.getEngineById(id); 1917 if (!engine || engine.hidden) { 1918 throw new Error("selectSearchEngine could not find engine with given ID"); 1919 } 1920 return Services.search.setDefault( 1921 engine, 1922 Ci.nsISearchService.CHANGE_REASON_UITOUR 1923 ); 1924 }, 1925 1926 notify(eventName, params) { 1927 for (let window of Services.wm.getEnumerator("navigator:browser")) { 1928 if (window.closed) { 1929 continue; 1930 } 1931 1932 let openTourBrowsers = this.tourBrowsersByWindow.get(window); 1933 if (!openTourBrowsers) { 1934 continue; 1935 } 1936 1937 for (let browser of openTourBrowsers) { 1938 let detail = { 1939 event: eventName, 1940 params, 1941 }; 1942 let contextToVisit = browser.browsingContext; 1943 let global = contextToVisit.currentWindowGlobal; 1944 let actor = global.getActor("UITour"); 1945 actor.sendAsyncMessage("UITour:SendPageNotification", detail); 1946 } 1947 } 1948 }, 1949 }; 1950 1951 UITour.init();