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