summaryrefslogtreecommitdiffstats
path: root/browser/modules
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /browser/modules
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'browser/modules')
-rw-r--r--browser/modules/AboutHome.jsm194
-rw-r--r--browser/modules/AboutNewTab.jsm43
-rw-r--r--browser/modules/AttributionCode.jsm123
-rw-r--r--browser/modules/BrowserUITelemetry.jsm896
-rw-r--r--browser/modules/BrowserUsageTelemetry.jsm468
-rw-r--r--browser/modules/CastingApps.jsm164
-rw-r--r--browser/modules/ContentClick.jsm97
-rw-r--r--browser/modules/ContentCrashHandlers.jsm1035
-rw-r--r--browser/modules/ContentLinkHandler.jsm147
-rw-r--r--browser/modules/ContentObservers.jsm55
-rw-r--r--browser/modules/ContentSearch.jsm566
-rw-r--r--browser/modules/ContentWebRTC.jsm392
-rw-r--r--browser/modules/DirectoryLinksProvider.jsm1255
-rw-r--r--browser/modules/E10SUtils.jsm128
-rw-r--r--browser/modules/Feeds.jsm104
-rw-r--r--browser/modules/FormSubmitObserver.jsm235
-rw-r--r--browser/modules/FormValidationHandler.jsm157
-rw-r--r--browser/modules/HiddenFrame.jsm86
-rw-r--r--browser/modules/LaterRun.jsm172
-rw-r--r--browser/modules/NetworkPrioritizer.jsm194
-rw-r--r--browser/modules/PermissionUI.jsm595
-rw-r--r--browser/modules/PluginContent.jsm1154
-rw-r--r--browser/modules/ProcessHangMonitor.jsm397
-rw-r--r--browser/modules/ReaderParent.jsm186
-rw-r--r--browser/modules/RecentWindow.jsm67
-rw-r--r--browser/modules/RemotePrompt.jsm110
-rw-r--r--browser/modules/Sanitizer.jsm22
-rw-r--r--browser/modules/SelfSupportBackend.jsm331
-rw-r--r--browser/modules/SitePermissions.jsm269
-rw-r--r--browser/modules/Social.jsm272
-rw-r--r--browser/modules/SocialService.jsm1097
-rw-r--r--browser/modules/TransientPrefs.jsm24
-rw-r--r--browser/modules/URLBarZoom.jsm51
-rw-r--r--browser/modules/Windows8WindowFrameColor.jsm53
-rw-r--r--browser/modules/WindowsJumpLists.jsm579
-rw-r--r--browser/modules/WindowsPreviewPerTab.jsm862
-rw-r--r--browser/modules/moz.build56
-rw-r--r--browser/modules/offlineAppCache.jsm20
-rw-r--r--browser/modules/test/.eslintrc.js7
-rw-r--r--browser/modules/test/browser.ini42
-rw-r--r--browser/modules/test/browser_BrowserUITelemetry_buckets.js97
-rw-r--r--browser/modules/test/browser_BrowserUITelemetry_defaults.js37
-rw-r--r--browser/modules/test/browser_BrowserUITelemetry_sidebar.js56
-rw-r--r--browser/modules/test/browser_BrowserUITelemetry_syncedtabs.js114
-rw-r--r--browser/modules/test/browser_ContentSearch.js425
-rw-r--r--browser/modules/test/browser_NetworkPrioritizer.js165
-rw-r--r--browser/modules/test/browser_PermissionUI.js445
-rw-r--r--browser/modules/test/browser_ProcessHangNotifications.js189
-rw-r--r--browser/modules/test/browser_SelfSupportBackend.js214
-rw-r--r--browser/modules/test/browser_UnsubmittedCrashHandler.js680
-rw-r--r--browser/modules/test/browser_UsageTelemetry.js268
-rw-r--r--browser/modules/test/browser_UsageTelemetry_content.js121
-rw-r--r--browser/modules/test/browser_UsageTelemetry_content_aboutHome.js84
-rw-r--r--browser/modules/test/browser_UsageTelemetry_private_and_restore.js90
-rw-r--r--browser/modules/test/browser_UsageTelemetry_searchbar.js195
-rw-r--r--browser/modules/test/browser_UsageTelemetry_urlbar.js220
-rw-r--r--browser/modules/test/browser_taskbar_preview.js100
-rw-r--r--browser/modules/test/browser_urlBar_zoom.js73
-rw-r--r--browser/modules/test/contentSearch.js64
-rw-r--r--browser/modules/test/contentSearchBadImage.xml6
-rw-r--r--browser/modules/test/contentSearchSuggestions.sjs9
-rw-r--r--browser/modules/test/contentSearchSuggestions.xml6
-rw-r--r--browser/modules/test/head.js113
-rw-r--r--browser/modules/test/unit/social/.eslintrc.js7
-rw-r--r--browser/modules/test/unit/social/blocklist.xml6
-rw-r--r--browser/modules/test/unit/social/head.js210
-rw-r--r--browser/modules/test/unit/social/test_SocialService.js166
-rw-r--r--browser/modules/test/unit/social/test_SocialServiceMigration21.js54
-rw-r--r--browser/modules/test/unit/social/test_SocialServiceMigration22.js67
-rw-r--r--browser/modules/test/unit/social/test_SocialServiceMigration29.js61
-rw-r--r--browser/modules/test/unit/social/test_social.js32
-rw-r--r--browser/modules/test/unit/social/test_socialDisabledStartup.js29
-rw-r--r--browser/modules/test/unit/social/xpcshell.ini13
-rw-r--r--browser/modules/test/usageTelemetrySearchSuggestions.sjs9
-rw-r--r--browser/modules/test/usageTelemetrySearchSuggestions.xml6
-rw-r--r--browser/modules/test/xpcshell/.eslintrc.js7
-rw-r--r--browser/modules/test/xpcshell/test_AttributionCode.js110
-rw-r--r--browser/modules/test/xpcshell/test_DirectoryLinksProvider.js1854
-rw-r--r--browser/modules/test/xpcshell/test_LaterRun.js138
-rw-r--r--browser/modules/test/xpcshell/test_SitePermissions.js115
-rw-r--r--browser/modules/test/xpcshell/xpcshell.ini11
-rw-r--r--browser/modules/webrtcUI.jsm963
82 files changed, 20334 insertions, 0 deletions
diff --git a/browser/modules/AboutHome.jsm b/browser/modules/AboutHome.jsm
new file mode 100644
index 000000000..01cbafba9
--- /dev/null
+++ b/browser/modules/AboutHome.jsm
@@ -0,0 +1,194 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = [ "AboutHomeUtils", "AboutHome" ];
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
+ "resource://gre/modules/AppConstants.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AutoMigrate",
+ "resource:///modules/AutoMigrate.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+ "resource://gre/modules/FxAccounts.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+
+// Url to fetch snippets, in the urlFormatter service format.
+const SNIPPETS_URL_PREF = "browser.aboutHomeSnippets.updateUrl";
+
+// Should be bumped up if the snippets content format changes.
+const STARTPAGE_VERSION = 4;
+
+this.AboutHomeUtils = {
+ get snippetsVersion() {
+ return STARTPAGE_VERSION;
+ },
+
+ /*
+ * showKnowYourRights - Determines if the user should be shown the
+ * about:rights notification. The notification should *not* be shown if
+ * we've already shown the current version, or if the override pref says to
+ * never show it. The notification *should* be shown if it's never been seen
+ * before, if a newer version is available, or if the override pref says to
+ * always show it.
+ */
+ get showKnowYourRights() {
+ // Look for an unconditional override pref. If set, do what it says.
+ // (true --> never show, false --> always show)
+ try {
+ return !Services.prefs.getBoolPref("browser.rights.override");
+ } catch (e) { }
+ // Ditto, for the legacy EULA pref.
+ try {
+ return !Services.prefs.getBoolPref("browser.EULA.override");
+ } catch (e) { }
+
+ if (!AppConstants.MOZILLA_OFFICIAL) {
+ // Non-official builds shouldn't show the notification.
+ return false;
+ }
+
+ // Look to see if the user has seen the current version or not.
+ var currentVersion = Services.prefs.getIntPref("browser.rights.version");
+ try {
+ return !Services.prefs.getBoolPref("browser.rights." + currentVersion + ".shown");
+ } catch (e) { }
+
+ // Legacy: If the user accepted a EULA, we won't annoy them with the
+ // equivalent about:rights page until the version changes.
+ try {
+ return !Services.prefs.getBoolPref("browser.EULA." + currentVersion + ".accepted");
+ } catch (e) { }
+
+ // We haven't shown the notification before, so do so now.
+ return true;
+ }
+};
+
+/**
+ * Returns the URL to fetch snippets from, in the urlFormatter service format.
+ */
+XPCOMUtils.defineLazyGetter(AboutHomeUtils, "snippetsURL", function() {
+ let updateURL = Services.prefs
+ .getCharPref(SNIPPETS_URL_PREF)
+ .replace("%STARTPAGE_VERSION%", STARTPAGE_VERSION);
+ return Services.urlFormatter.formatURL(updateURL);
+});
+
+/**
+ * This code provides services to the about:home page. Whenever
+ * about:home needs to do something chrome-privileged, it sends a
+ * message that's handled here.
+ */
+var AboutHome = {
+ MESSAGES: [
+ "AboutHome:RestorePreviousSession",
+ "AboutHome:Downloads",
+ "AboutHome:Bookmarks",
+ "AboutHome:History",
+ "AboutHome:Addons",
+ "AboutHome:Sync",
+ "AboutHome:Settings",
+ "AboutHome:RequestUpdate",
+ "AboutHome:MaybeShowAutoMigrationUndoNotification",
+ ],
+
+ init: function() {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
+
+ for (let msg of this.MESSAGES) {
+ mm.addMessageListener(msg, this);
+ }
+ },
+
+ receiveMessage: function(aMessage) {
+ let window = aMessage.target.ownerGlobal;
+
+ switch (aMessage.name) {
+ case "AboutHome:RestorePreviousSession":
+ let ss = Cc["@mozilla.org/browser/sessionstore;1"].
+ getService(Ci.nsISessionStore);
+ if (ss.canRestoreLastSession) {
+ ss.restoreLastSession();
+ }
+ break;
+
+ case "AboutHome:Downloads":
+ window.BrowserDownloadsUI();
+ break;
+
+ case "AboutHome:Bookmarks":
+ window.PlacesCommandHook.showPlacesOrganizer("UnfiledBookmarks");
+ break;
+
+ case "AboutHome:History":
+ window.PlacesCommandHook.showPlacesOrganizer("History");
+ break;
+
+ case "AboutHome:Addons":
+ window.BrowserOpenAddonsMgr();
+ break;
+
+ case "AboutHome:Sync":
+ window.openPreferences("paneSync", { urlParams: { entrypoint: "abouthome" } });
+ break;
+
+ case "AboutHome:Settings":
+ window.openPreferences();
+ break;
+
+ case "AboutHome:RequestUpdate":
+ this.sendAboutHomeData(aMessage.target);
+ break;
+
+ case "AboutHome:MaybeShowAutoMigrationUndoNotification":
+ AutoMigrate.maybeShowUndoNotification(aMessage.target);
+ break;
+ }
+ },
+
+ // Send all the chrome-privileged data needed by about:home. This
+ // gets re-sent when the search engine changes.
+ sendAboutHomeData: function(target) {
+ let wrapper = {};
+ Components.utils.import("resource:///modules/sessionstore/SessionStore.jsm",
+ wrapper);
+ let ss = wrapper.SessionStore;
+
+ ss.promiseInitialized.then(function() {
+ let data = {
+ showRestoreLastSession: ss.canRestoreLastSession,
+ snippetsURL: AboutHomeUtils.snippetsURL,
+ showKnowYourRights: AboutHomeUtils.showKnowYourRights,
+ snippetsVersion: AboutHomeUtils.snippetsVersion,
+ };
+
+ if (AboutHomeUtils.showKnowYourRights) {
+ // Set pref to indicate we've shown the notification.
+ let currentVersion = Services.prefs.getIntPref("browser.rights.version");
+ Services.prefs.setBoolPref("browser.rights." + currentVersion + ".shown", true);
+ }
+
+ if (target && target.messageManager) {
+ target.messageManager.sendAsyncMessage("AboutHome:Update", data);
+ } else {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
+ mm.broadcastAsyncMessage("AboutHome:Update", data);
+ }
+ }).then(null, function onError(x) {
+ Cu.reportError("Error in AboutHome.sendAboutHomeData: " + x);
+ });
+ },
+
+};
diff --git a/browser/modules/AboutNewTab.jsm b/browser/modules/AboutNewTab.jsm
new file mode 100644
index 000000000..4337c5a2d
--- /dev/null
+++ b/browser/modules/AboutNewTab.jsm
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = [ "AboutNewTab" ];
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AutoMigrate",
+ "resource:///modules/AutoMigrate.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
+ "resource://gre/modules/NewTabUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "RemotePages",
+ "resource://gre/modules/RemotePageManager.jsm");
+
+var AboutNewTab = {
+
+ pageListener: null,
+
+ init: function() {
+ this.pageListener = new RemotePages("about:newtab");
+ this.pageListener.addMessageListener("NewTab:Customize", this.customize.bind(this));
+ this.pageListener.addMessageListener("NewTab:MaybeShowAutoMigrationUndoNotification",
+ (msg) => AutoMigrate.maybeShowUndoNotification(msg.target.browser));
+ },
+
+ customize: function(message) {
+ NewTabUtils.allPages.enabled = message.data.enabled;
+ NewTabUtils.allPages.enhanced = message.data.enhanced;
+ },
+
+ uninit: function() {
+ this.pageListener.destroy();
+ this.pageListener = null;
+ },
+};
diff --git a/browser/modules/AttributionCode.jsm b/browser/modules/AttributionCode.jsm
new file mode 100644
index 000000000..dc42b2be4
--- /dev/null
+++ b/browser/modules/AttributionCode.jsm
@@ -0,0 +1,123 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["AttributionCode"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, 'AppConstants',
+ 'resource://gre/modules/AppConstants.jsm');
+XPCOMUtils.defineLazyModuleGetter(this, 'OS',
+ 'resource://gre/modules/osfile.jsm');
+XPCOMUtils.defineLazyModuleGetter(this, 'Services',
+ 'resource://gre/modules/Services.jsm');
+XPCOMUtils.defineLazyModuleGetter(this, 'Task',
+ 'resource://gre/modules/Task.jsm');
+
+const ATTR_CODE_MAX_LENGTH = 200;
+const ATTR_CODE_KEYS_REGEX = /^source|medium|campaign|content$/;
+const ATTR_CODE_VALUE_REGEX = /[a-zA-Z0-9_%\\-\\.\\(\\)]*/;
+const ATTR_CODE_FIELD_SEPARATOR = "%26"; // URL-encoded &
+const ATTR_CODE_KEY_VALUE_SEPARATOR = "%3D"; // URL-encoded =
+
+let gCachedAttrData = null;
+
+/**
+ * Returns an nsIFile for the file containing the attribution data.
+ */
+function getAttributionFile() {
+ let file = Services.dirsvc.get("LocalAppData", Ci.nsIFile);
+ // appinfo does not exist in xpcshell, so we need defaults.
+ file.append(Services.appinfo.vendor || "mozilla");
+ file.append(AppConstants.MOZ_APP_NAME);
+ file.append("postSigningData");
+ return file;
+}
+
+/**
+ * Returns an object containing a key-value pair for each piece of attribution
+ * data included in the passed-in attribution code string.
+ * If the string isn't a valid attribution code, returns an empty object.
+ */
+function parseAttributionCode(code) {
+ if (code.length > ATTR_CODE_MAX_LENGTH) {
+ return {};
+ }
+
+ let isValid = true;
+ let parsed = {};
+ for (let param of code.split(ATTR_CODE_FIELD_SEPARATOR)) {
+ let [key, value] = param.split(ATTR_CODE_KEY_VALUE_SEPARATOR, 2);
+ if (key && ATTR_CODE_KEYS_REGEX.test(key)) {
+ if (value && ATTR_CODE_VALUE_REGEX.test(value)) {
+ parsed[key] = value;
+ }
+ } else {
+ isValid = false;
+ break;
+ }
+ }
+ return isValid ? parsed : {};
+}
+
+var AttributionCode = {
+ /**
+ * Reads the attribution code, either from disk or a cached version.
+ * Returns a promise that fulfills with an object containing the parsed
+ * attribution data if the code could be read and is valid,
+ * or an empty object otherwise.
+ */
+ getAttrDataAsync() {
+ return Task.spawn(function*() {
+ if (gCachedAttrData != null) {
+ return gCachedAttrData;
+ }
+
+ let code = "";
+ try {
+ let bytes = yield OS.File.read(getAttributionFile().path);
+ let decoder = new TextDecoder();
+ code = decoder.decode(bytes);
+ } catch (ex) {
+ // The attribution file may already have been deleted,
+ // or it may have never been installed at all;
+ // failure to open or read it isn't an error.
+ }
+
+ gCachedAttrData = parseAttributionCode(code);
+ return gCachedAttrData;
+ });
+ },
+
+ /**
+ * Deletes the attribution data file.
+ * Returns a promise that resolves when the file is deleted,
+ * or if the file couldn't be deleted (the promise is never rejected).
+ */
+ deleteFileAsync() {
+ return Task.spawn(function*() {
+ try {
+ yield OS.File.remove(getAttributionFile().path);
+ } catch (ex) {
+ // The attribution file may already have been deleted,
+ // or it may have never been installed at all;
+ // failure to delete it isn't an error.
+ }
+ });
+ },
+
+ /**
+ * Clears the cached attribution code value, if any.
+ * Does nothing if called from outside of an xpcshell test.
+ */
+ _clearCache() {
+ let env = Cc["@mozilla.org/process/environment;1"]
+ .getService(Ci.nsIEnvironment);
+ if (env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ gCachedAttrData = null;
+ }
+ },
+};
diff --git a/browser/modules/BrowserUITelemetry.jsm b/browser/modules/BrowserUITelemetry.jsm
new file mode 100644
index 000000000..392462b45
--- /dev/null
+++ b/browser/modules/BrowserUITelemetry.jsm
@@ -0,0 +1,896 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["BrowserUITelemetry"];
+
+const {interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry",
+ "resource://gre/modules/UITelemetry.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
+ "resource:///modules/RecentWindow.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
+ "resource:///modules/CustomizableUI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "UITour",
+ "resource:///modules/UITour.jsm");
+XPCOMUtils.defineLazyGetter(this, "Timer", function() {
+ let timer = {};
+ Cu.import("resource://gre/modules/Timer.jsm", timer);
+ return timer;
+});
+
+const MS_SECOND = 1000;
+const MS_MINUTE = MS_SECOND * 60;
+const MS_HOUR = MS_MINUTE * 60;
+
+XPCOMUtils.defineLazyGetter(this, "DEFAULT_AREA_PLACEMENTS", function() {
+ let result = {
+ "PanelUI-contents": [
+ "edit-controls",
+ "zoom-controls",
+ "new-window-button",
+ "privatebrowsing-button",
+ "save-page-button",
+ "print-button",
+ "history-panelmenu",
+ "fullscreen-button",
+ "find-button",
+ "preferences-button",
+ "add-ons-button",
+ "sync-button",
+ "developer-button",
+ ],
+ "nav-bar": [
+ "urlbar-container",
+ "search-container",
+ "bookmarks-menu-button",
+ "pocket-button",
+ "downloads-button",
+ "home-button",
+ "social-share-button",
+ ],
+ // It's true that toolbar-menubar is not visible
+ // on OS X, but the XUL node is definitely present
+ // in the document.
+ "toolbar-menubar": [
+ "menubar-items",
+ ],
+ "TabsToolbar": [
+ "tabbrowser-tabs",
+ "new-tab-button",
+ "alltabs-button",
+ ],
+ "PersonalToolbar": [
+ "personal-bookmarks",
+ ],
+ };
+
+ let showCharacterEncoding = Services.prefs.getComplexValue(
+ "browser.menu.showCharacterEncoding",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ if (showCharacterEncoding == "true") {
+ result["PanelUI-contents"].push("characterencoding-button");
+ }
+
+ return result;
+});
+
+XPCOMUtils.defineLazyGetter(this, "DEFAULT_AREAS", function() {
+ return Object.keys(DEFAULT_AREA_PLACEMENTS);
+});
+
+XPCOMUtils.defineLazyGetter(this, "PALETTE_ITEMS", function() {
+ let result = [
+ "open-file-button",
+ "developer-button",
+ "feed-button",
+ "email-link-button",
+ "containers-panelmenu",
+ ];
+
+ let panelPlacements = DEFAULT_AREA_PLACEMENTS["PanelUI-contents"];
+ if (panelPlacements.indexOf("characterencoding-button") == -1) {
+ result.push("characterencoding-button");
+ }
+
+ if (Services.prefs.getBoolPref("privacy.panicButton.enabled")) {
+ result.push("panic-button");
+ }
+
+ return result;
+});
+
+XPCOMUtils.defineLazyGetter(this, "DEFAULT_ITEMS", function() {
+ let result = [];
+ for (let [, buttons] of Object.entries(DEFAULT_AREA_PLACEMENTS)) {
+ result = result.concat(buttons);
+ }
+ return result;
+});
+
+XPCOMUtils.defineLazyGetter(this, "ALL_BUILTIN_ITEMS", function() {
+ // These special cases are for click events on built-in items that are
+ // contained within customizable items (like the navigation widget).
+ const SPECIAL_CASES = [
+ "back-button",
+ "forward-button",
+ "urlbar-stop-button",
+ "urlbar-go-button",
+ "urlbar-reload-button",
+ "searchbar",
+ "cut-button",
+ "copy-button",
+ "paste-button",
+ "zoom-out-button",
+ "zoom-reset-button",
+ "zoom-in-button",
+ "BMB_bookmarksPopup",
+ "BMB_unsortedBookmarksPopup",
+ "BMB_bookmarksToolbarPopup",
+ "search-go-button",
+ "soundplaying-icon",
+ ]
+ return DEFAULT_ITEMS.concat(PALETTE_ITEMS)
+ .concat(SPECIAL_CASES);
+});
+
+const OTHER_MOUSEUP_MONITORED_ITEMS = [
+ "PlacesChevron",
+ "PlacesToolbarItems",
+ "menubar-items",
+];
+
+// Items that open arrow panels will often be overlapped by
+// the panel that they're opening by the time the mouseup
+// event is fired, so for these items, we monitor mousedown.
+const MOUSEDOWN_MONITORED_ITEMS = [
+ "PanelUI-menu-button",
+];
+
+// Weakly maps browser windows to objects whose keys are relative
+// timestamps for when some kind of session started. For example,
+// when a customization session started. That way, when the window
+// exits customization mode, we can determine how long the session
+// lasted.
+const WINDOW_DURATION_MAP = new WeakMap();
+
+// Default bucket name, when no other bucket is active.
+const BUCKET_DEFAULT = "__DEFAULT__";
+// Bucket prefix, for named buckets.
+const BUCKET_PREFIX = "bucket_";
+// Standard separator to use between different parts of a bucket name, such
+// as primary name and the time step string.
+const BUCKET_SEPARATOR = "|";
+
+this.BrowserUITelemetry = {
+ init: function() {
+ UITelemetry.addSimpleMeasureFunction("toolbars",
+ this.getToolbarMeasures.bind(this));
+ UITelemetry.addSimpleMeasureFunction("contextmenu",
+ this.getContextMenuInfo.bind(this));
+ // Ensure that UITour.jsm remains lazy-loaded, yet always registers its
+ // simple measure function with UITelemetry.
+ UITelemetry.addSimpleMeasureFunction("UITour",
+ () => UITour.getTelemetry());
+
+ UITelemetry.addSimpleMeasureFunction("syncstate",
+ this.getSyncState.bind(this));
+
+ Services.obs.addObserver(this, "sessionstore-windows-restored", false);
+ Services.obs.addObserver(this, "browser-delayed-startup-finished", false);
+ Services.obs.addObserver(this, "autocomplete-did-enter-text", false);
+ CustomizableUI.addListener(this);
+ },
+
+ observe: function(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "sessionstore-windows-restored":
+ this._gatherFirstWindowMeasurements();
+ break;
+ case "browser-delayed-startup-finished":
+ this._registerWindow(aSubject);
+ break;
+ case "autocomplete-did-enter-text":
+ let input = aSubject.QueryInterface(Ci.nsIAutoCompleteInput);
+ if (input && input.id == "urlbar" && !input.inPrivateContext &&
+ input.popup.selectedIndex != -1) {
+ this._logAwesomeBarSearchResult(input.textValue);
+ }
+ break;
+ }
+ },
+
+ /**
+ * For the _countableEvents object, constructs a chain of
+ * Javascript Objects with the keys in aKeys, with the final
+ * key getting the value in aEndWith. If the final key already
+ * exists in the final object, its value is not set. In either
+ * case, a reference to the second last object in the chain is
+ * returned.
+ *
+ * Example - suppose I want to store:
+ * _countableEvents: {
+ * a: {
+ * b: {
+ * c: 0
+ * }
+ * }
+ * }
+ *
+ * And then increment the "c" value by 1, you could call this
+ * function like this:
+ *
+ * let example = this._ensureObjectChain([a, b, c], 0);
+ * example["c"]++;
+ *
+ * Subsequent repetitions of these last two lines would
+ * simply result in the c value being incremented again
+ * and again.
+ *
+ * @param aKeys the Array of keys to chain Objects together with.
+ * @param aEndWith the value to assign to the last key.
+ * @param aRoot the root object onto which we create/get the object chain
+ * designated by aKeys.
+ * @returns a reference to the second last object in the chain -
+ * so in our example, that'd be "b".
+ */
+ _ensureObjectChain: function(aKeys, aEndWith, aRoot) {
+ let current = aRoot;
+ let parent = null;
+ aKeys.unshift(this._bucket);
+ for (let [i, key] of aKeys.entries()) {
+ if (!(key in current)) {
+ if (i == aKeys.length - 1) {
+ current[key] = aEndWith;
+ } else {
+ current[key] = {};
+ }
+ }
+ parent = current;
+ current = current[key];
+ }
+ return parent;
+ },
+
+ _countableEvents: {},
+ _countEvent: function(aKeyArray, root=this._countableEvents) {
+ let countObject = this._ensureObjectChain(aKeyArray, 0, root);
+ let lastItemKey = aKeyArray[aKeyArray.length - 1];
+ countObject[lastItemKey]++;
+ },
+
+ _countMouseUpEvent: function(aCategory, aAction, aButton) {
+ const BUTTONS = ["left", "middle", "right"];
+ let buttonKey = BUTTONS[aButton];
+ if (buttonKey) {
+ this._countEvent([aCategory, aAction, buttonKey]);
+ }
+ },
+
+ _firstWindowMeasurements: null,
+ _gatherFirstWindowMeasurements: function() {
+ // We'll gather measurements as soon as the session has restored.
+ // We do this here instead of waiting for UITelemetry to ask for
+ // our measurements because at that point all browser windows have
+ // probably been closed, since the vast majority of saved-session
+ // pings are gathered during shutdown.
+ let win = RecentWindow.getMostRecentBrowserWindow({
+ private: false,
+ allowPopups: false,
+ });
+
+ Services.search.init(rv => {
+ // If there are no such windows (or we've just about found one
+ // but it's closed already), we're out of luck. :(
+ let hasWindow = win && !win.closed;
+ this._firstWindowMeasurements = hasWindow ? this._getWindowMeasurements(win, rv)
+ : {};
+ });
+ },
+
+ _registerWindow: function(aWindow) {
+ aWindow.addEventListener("unload", this);
+ let document = aWindow.document;
+
+ for (let areaID of CustomizableUI.areas) {
+ let areaNode = document.getElementById(areaID);
+ if (areaNode) {
+ (areaNode.customizationTarget || areaNode).addEventListener("mouseup", this);
+ }
+ }
+
+ for (let itemID of OTHER_MOUSEUP_MONITORED_ITEMS) {
+ let item = document.getElementById(itemID);
+ if (item) {
+ item.addEventListener("mouseup", this);
+ }
+ }
+
+ for (let itemID of MOUSEDOWN_MONITORED_ITEMS) {
+ let item = document.getElementById(itemID);
+ if (item) {
+ item.addEventListener("mousedown", this);
+ }
+ }
+
+ WINDOW_DURATION_MAP.set(aWindow, {});
+ },
+
+ _unregisterWindow: function(aWindow) {
+ aWindow.removeEventListener("unload", this);
+ let document = aWindow.document;
+
+ for (let areaID of CustomizableUI.areas) {
+ let areaNode = document.getElementById(areaID);
+ if (areaNode) {
+ (areaNode.customizationTarget || areaNode).removeEventListener("mouseup", this);
+ }
+ }
+
+ for (let itemID of OTHER_MOUSEUP_MONITORED_ITEMS) {
+ let item = document.getElementById(itemID);
+ if (item) {
+ item.removeEventListener("mouseup", this);
+ }
+ }
+
+ for (let itemID of MOUSEDOWN_MONITORED_ITEMS) {
+ let item = document.getElementById(itemID);
+ if (item) {
+ item.removeEventListener("mousedown", this);
+ }
+ }
+ },
+
+ handleEvent: function(aEvent) {
+ switch (aEvent.type) {
+ case "unload":
+ this._unregisterWindow(aEvent.currentTarget);
+ break;
+ case "mouseup":
+ this._handleMouseUp(aEvent);
+ break;
+ case "mousedown":
+ this._handleMouseDown(aEvent);
+ break;
+ }
+ },
+
+ _handleMouseUp: function(aEvent) {
+ let targetID = aEvent.currentTarget.id;
+
+ switch (targetID) {
+ case "PlacesToolbarItems":
+ this._PlacesToolbarItemsMouseUp(aEvent);
+ break;
+ case "PlacesChevron":
+ this._PlacesChevronMouseUp(aEvent);
+ break;
+ case "menubar-items":
+ this._menubarMouseUp(aEvent);
+ break;
+ default:
+ this._checkForBuiltinItem(aEvent);
+ }
+ },
+
+ _handleMouseDown: function(aEvent) {
+ if (aEvent.currentTarget.id == "PanelUI-menu-button") {
+ // _countMouseUpEvent expects a detail for the second argument,
+ // but we don't really have any details to give. Just passing in
+ // "button" is probably simpler than trying to modify
+ // _countMouseUpEvent for this particular case.
+ this._countMouseUpEvent("click-menu-button", "button", aEvent.button);
+ }
+ },
+
+ _PlacesChevronMouseUp: function(aEvent) {
+ let target = aEvent.originalTarget;
+ let result = target.id == "PlacesChevron" ? "chevron" : "overflowed-item";
+ this._countMouseUpEvent("click-bookmarks-bar", result, aEvent.button);
+ },
+
+ _PlacesToolbarItemsMouseUp: function(aEvent) {
+ let target = aEvent.originalTarget;
+ // If this isn't a bookmark-item, we don't care about it.
+ if (!target.classList.contains("bookmark-item")) {
+ return;
+ }
+
+ let result = target.hasAttribute("container") ? "container" : "item";
+ this._countMouseUpEvent("click-bookmarks-bar", result, aEvent.button);
+ },
+
+ _menubarMouseUp: function(aEvent) {
+ let target = aEvent.originalTarget;
+ let tag = target.localName
+ let result = (tag == "menu" || tag == "menuitem") ? tag : "other";
+ this._countMouseUpEvent("click-menubar", result, aEvent.button);
+ },
+
+ _bookmarksMenuButtonMouseUp: function(aEvent) {
+ let bookmarksWidget = CustomizableUI.getWidget("bookmarks-menu-button");
+ if (bookmarksWidget.areaType == CustomizableUI.TYPE_MENU_PANEL) {
+ // In the menu panel, only the star is visible, and that opens up the
+ // bookmarks subview.
+ this._countMouseUpEvent("click-bookmarks-menu-button", "in-panel",
+ aEvent.button);
+ } else {
+ let clickedItem = aEvent.originalTarget;
+ // Did we click on the star, or the dropmarker? The star
+ // has an anonid of "button". If we don't find that, we'll
+ // assume we clicked on the dropmarker.
+ let action = "menu";
+ if (clickedItem.getAttribute("anonid") == "button") {
+ // We clicked on the star - now we just need to record
+ // whether or not we're adding a bookmark or editing an
+ // existing one.
+ let bookmarksMenuNode =
+ bookmarksWidget.forWindow(aEvent.target.ownerGlobal).node;
+ action = bookmarksMenuNode.hasAttribute("starred") ? "edit" : "add";
+ }
+ this._countMouseUpEvent("click-bookmarks-menu-button", action,
+ aEvent.button);
+ }
+ },
+
+ _checkForBuiltinItem: function(aEvent) {
+ let item = aEvent.originalTarget;
+
+ // We don't want to count clicks on the private browsing
+ // button for privacy reasons. See bug 1176391.
+ if (item.id == "privatebrowsing-button") {
+ return;
+ }
+
+ // We special-case the bookmarks-menu-button, since we want to
+ // monitor more than just clicks on it.
+ if (item.id == "bookmarks-menu-button" ||
+ getIDBasedOnFirstIDedAncestor(item) == "bookmarks-menu-button") {
+ this._bookmarksMenuButtonMouseUp(aEvent);
+ return;
+ }
+
+ // Perhaps we're seeing one of the default toolbar items
+ // being clicked.
+ if (ALL_BUILTIN_ITEMS.indexOf(item.id) != -1) {
+ // Base case - we clicked directly on one of our built-in items,
+ // and we can go ahead and register that click.
+ this._countMouseUpEvent("click-builtin-item", item.id, aEvent.button);
+ return;
+ }
+
+ // If not, we need to check if the item's anonid is in our list
+ // of built-in items to check.
+ if (ALL_BUILTIN_ITEMS.indexOf(item.getAttribute("anonid")) != -1) {
+ this._countMouseUpEvent("click-builtin-item", item.getAttribute("anonid"), aEvent.button);
+ return;
+ }
+
+ // If not, we need to check if one of the ancestors of the clicked
+ // item is in our list of built-in items to check.
+ let candidate = getIDBasedOnFirstIDedAncestor(item);
+ if (ALL_BUILTIN_ITEMS.indexOf(candidate) != -1) {
+ this._countMouseUpEvent("click-builtin-item", candidate, aEvent.button);
+ }
+ },
+
+ _getWindowMeasurements: function(aWindow, searchResult) {
+ let document = aWindow.document;
+ let result = {};
+
+ // Determine if the window is in the maximized, normal or
+ // fullscreen state.
+ result.sizemode = document.documentElement.getAttribute("sizemode");
+
+ // Determine if the Bookmarks bar is currently visible
+ let bookmarksBar = document.getElementById("PersonalToolbar");
+ result.bookmarksBarEnabled = bookmarksBar && !bookmarksBar.collapsed;
+
+ // Determine if the menubar is currently visible. On OS X, the menubar
+ // is never shown, despite not having the collapsed attribute set.
+ let menuBar = document.getElementById("toolbar-menubar");
+ result.menuBarEnabled =
+ menuBar && Services.appinfo.OS != "Darwin"
+ && menuBar.getAttribute("autohide") != "true";
+
+ // Determine if the titlebar is currently visible.
+ result.titleBarEnabled = !Services.prefs.getBoolPref("browser.tabs.drawInTitlebar");
+
+ // Examine all customizable areas and see what default items
+ // are present and missing.
+ let defaultKept = [];
+ let defaultMoved = [];
+ let nondefaultAdded = [];
+
+ for (let areaID of CustomizableUI.areas) {
+ let items = CustomizableUI.getWidgetIdsInArea(areaID);
+ for (let item of items) {
+ // Is this a default item?
+ if (DEFAULT_ITEMS.indexOf(item) != -1) {
+ // Ok, it's a default item - but is it in its default
+ // toolbar? We use Array.isArray instead of checking for
+ // toolbarID in DEFAULT_AREA_PLACEMENTS because an add-on might
+ // be clever and give itself the id of "toString" or something.
+ if (Array.isArray(DEFAULT_AREA_PLACEMENTS[areaID]) &&
+ DEFAULT_AREA_PLACEMENTS[areaID].indexOf(item) != -1) {
+ // The item is in its default toolbar
+ defaultKept.push(item);
+ } else {
+ defaultMoved.push(item);
+ }
+ } else if (PALETTE_ITEMS.indexOf(item) != -1) {
+ // It's a palette item that's been moved into a toolbar
+ nondefaultAdded.push(item);
+ }
+ // else, it's provided by an add-on, and we won't record it.
+ }
+ }
+
+ // Now go through the items in the palette to see what default
+ // items are in there.
+ let paletteItems =
+ CustomizableUI.getUnusedWidgets(aWindow.gNavToolbox.palette);
+ let defaultRemoved = [];
+ for (let item of paletteItems) {
+ if (DEFAULT_ITEMS.indexOf(item.id) != -1) {
+ defaultRemoved.push(item.id);
+ }
+ }
+
+ result.defaultKept = defaultKept;
+ result.defaultMoved = defaultMoved;
+ result.nondefaultAdded = nondefaultAdded;
+ result.defaultRemoved = defaultRemoved;
+
+ // Next, determine how many add-on provided toolbars exist.
+ let addonToolbars = 0;
+ let toolbars = document.querySelectorAll("toolbar[customizable=true]");
+ for (let toolbar of toolbars) {
+ if (DEFAULT_AREAS.indexOf(toolbar.id) == -1) {
+ addonToolbars++;
+ }
+ }
+ result.addonToolbars = addonToolbars;
+
+ // Find out how many open tabs we have in each window
+ let winEnumerator = Services.wm.getEnumerator("navigator:browser");
+ let visibleTabs = [];
+ let hiddenTabs = [];
+ while (winEnumerator.hasMoreElements()) {
+ let someWin = winEnumerator.getNext();
+ if (someWin.gBrowser) {
+ let visibleTabsNum = someWin.gBrowser.visibleTabs.length;
+ visibleTabs.push(visibleTabsNum);
+ hiddenTabs.push(someWin.gBrowser.tabs.length - visibleTabsNum);
+ }
+ }
+ result.visibleTabs = visibleTabs;
+ result.hiddenTabs = hiddenTabs;
+
+ if (Components.isSuccessCode(searchResult)) {
+ result.currentSearchEngine = Services.search.currentEngine.name;
+ }
+
+ return result;
+ },
+
+ getToolbarMeasures: function() {
+ let result = this._firstWindowMeasurements || {};
+ result.countableEvents = this._countableEvents;
+ result.durations = this._durations;
+ return result;
+ },
+
+ getSyncState: function() {
+ let result = {};
+ for (let sub of ["desktop", "mobile"]) {
+ let count = 0;
+ try {
+ count = Services.prefs.getIntPref("services.sync.clients.devices." + sub);
+ } catch (ex) {}
+ result[sub] = count;
+ }
+ return result;
+ },
+
+ countCustomizationEvent: function(aEventType) {
+ this._countEvent(["customize", aEventType]);
+ },
+
+ countSearchEvent: function(source, query, selection) {
+ this._countEvent(["search", source]);
+ if ((/^[a-zA-Z]+:[^\/\\]/).test(query)) {
+ this._countEvent(["search", "urlbar-keyword"]);
+ }
+ if (selection) {
+ this._countEvent(["search", "selection", source, selection.index, selection.kind]);
+ }
+ },
+
+ countOneoffSearchEvent: function(id, type, where) {
+ this._countEvent(["search-oneoff", id, type, where]);
+ },
+
+ countSearchSettingsEvent: function(source) {
+ this._countEvent(["click-builtin-item", source, "search-settings"]);
+ },
+
+ countPanicEvent: function(timeId) {
+ this._countEvent(["forget-button", timeId]);
+ },
+
+ countTabMutingEvent: function(action, reason) {
+ this._countEvent(["tab-audio-control", action, reason || "no reason given"]);
+ },
+
+ countSyncedTabEvent: function(what, where) {
+ // "what" will be, eg, "open"
+ // "where" will be "toolbarbutton-subview" or "sidebar"
+ this._countEvent(["synced-tabs", what, where]);
+ },
+
+ countSidebarEvent: function(sidebarID, action) {
+ // sidebarID is the ID of the sidebar (duh!)
+ // action will be "hide" or "show"
+ this._countEvent(["sidebar", sidebarID, action]);
+ },
+
+ _logAwesomeBarSearchResult: function (url) {
+ let spec = Services.search.parseSubmissionURL(url);
+ if (spec.engine) {
+ let matchedEngine = "default";
+ if (spec.engine.name !== Services.search.currentEngine.name) {
+ matchedEngine = "other";
+ }
+ this.countSearchEvent("autocomplete-" + matchedEngine);
+ }
+ },
+
+ _durations: {
+ customization: [],
+ },
+
+ onCustomizeStart: function(aWindow) {
+ this._countEvent(["customize", "start"]);
+ let durationMap = WINDOW_DURATION_MAP.get(aWindow);
+ if (!durationMap) {
+ durationMap = {};
+ WINDOW_DURATION_MAP.set(aWindow, durationMap);
+ }
+
+ durationMap.customization = {
+ start: aWindow.performance.now(),
+ bucket: this._bucket,
+ };
+ },
+
+ onCustomizeEnd: function(aWindow) {
+ let durationMap = WINDOW_DURATION_MAP.get(aWindow);
+ if (durationMap && "customization" in durationMap) {
+ let duration = aWindow.performance.now() - durationMap.customization.start;
+ this._durations.customization.push({
+ duration: duration,
+ bucket: durationMap.customization.bucket,
+ });
+ delete durationMap.customization;
+ }
+ },
+
+ _contextMenuItemWhitelist: new Set([
+ "close-without-interaction", // for closing the menu without clicking it.
+ "custom-page-item", // The ID we use for page-provided items
+ "unknown", // The bucket for stuff with no id.
+ // Everything we know of so far (which will exclude add-on items):
+ "navigation", "back", "forward", "reload", "stop", "bookmarkpage",
+ "spell-no-suggestions", "spell-add-to-dictionary",
+ "spell-undo-add-to-dictionary", "openlinkincurrent", "openlinkintab",
+ "openlink",
+ // "openlinkprivate" intentionally omitted for privacy reasons. See bug 1176391.
+ "bookmarklink", "sharelink", "savelink",
+ "marklinkMenu", "copyemail", "copylink", "media-play", "media-pause",
+ "media-mute", "media-unmute", "media-playbackrate",
+ "media-playbackrate-050x", "media-playbackrate-100x",
+ "media-playbackrate-125x", "media-playbackrate-150x", "media-playbackrate-200x",
+ "media-showcontrols", "media-hidecontrols",
+ "video-fullscreen", "leave-dom-fullscreen",
+ "reloadimage", "viewimage", "viewvideo", "copyimage-contents", "copyimage",
+ "copyvideourl", "copyaudiourl", "saveimage", "shareimage", "sendimage",
+ "setDesktopBackground", "viewimageinfo", "viewimagedesc", "savevideo",
+ "sharevideo", "saveaudio", "video-saveimage", "sendvideo", "sendaudio",
+ "ctp-play", "ctp-hide", "sharepage", "savepage", "pocket", "markpageMenu",
+ "viewbgimage", "undo", "cut", "copy", "paste", "delete", "selectall",
+ "keywordfield", "searchselect", "shareselect", "frame", "showonlythisframe",
+ "openframeintab", "openframe", "reloadframe", "bookmarkframe", "saveframe",
+ "printframe", "viewframesource", "viewframeinfo",
+ "viewpartialsource-selection", "viewpartialsource-mathml",
+ "viewsource", "viewinfo", "spell-check-enabled",
+ "spell-add-dictionaries-main", "spell-dictionaries",
+ "spell-dictionaries-menu", "spell-add-dictionaries",
+ "bidi-text-direction-toggle", "bidi-page-direction-toggle", "inspect",
+ "media-eme-learn-more"
+ ]),
+
+ _contextMenuInteractions: {},
+
+ registerContextMenuInteraction: function(keys, itemID) {
+ if (itemID) {
+ if (itemID == "openlinkprivate") {
+ // Don't record anything, not even an other-item count
+ // if the user chose to open in a private window. See
+ // bug 1176391.
+ return;
+ }
+
+ if (!this._contextMenuItemWhitelist.has(itemID)) {
+ itemID = "other-item";
+ }
+ keys.push(itemID);
+ }
+
+ this._countEvent(keys, this._contextMenuInteractions);
+ },
+
+ getContextMenuInfo: function() {
+ return this._contextMenuInteractions;
+ },
+
+ _bucket: BUCKET_DEFAULT,
+ _bucketTimer: null,
+
+ /**
+ * Default bucket name, when no other bucket is active.
+ */
+ get BUCKET_DEFAULT() {
+ return BUCKET_DEFAULT;
+ },
+
+ /**
+ * Bucket prefix, for named buckets.
+ */
+ get BUCKET_PREFIX() {
+ return BUCKET_PREFIX;
+ },
+
+ /**
+ * Standard separator to use between different parts of a bucket name, such
+ * as primary name and the time step string.
+ */
+ get BUCKET_SEPARATOR() {
+ return BUCKET_SEPARATOR;
+ },
+
+ get currentBucket() {
+ return this._bucket;
+ },
+
+ /**
+ * Sets a named bucket for all countable events and select durections to be
+ * put into.
+ *
+ * @param aName Name of bucket, or null for default bucket name (__DEFAULT__)
+ */
+ setBucket: function(aName) {
+ if (this._bucketTimer) {
+ Timer.clearTimeout(this._bucketTimer);
+ this._bucketTimer = null;
+ }
+
+ if (aName)
+ this._bucket = BUCKET_PREFIX + aName;
+ else
+ this._bucket = BUCKET_DEFAULT;
+ },
+
+ /**
+ * Sets a bucket that expires at the rate of a given series of time steps.
+ * Once the bucket expires, the current bucket will automatically revert to
+ * the default bucket. While the bucket is expiring, it's name is postfixed
+ * by '|' followed by a short string representation of the time step it's
+ * currently in.
+ * If any other bucket (expiring or normal) is set while an expiring bucket is
+ * still expiring, the old expiring bucket stops expiring and the new bucket
+ * immediately takes over.
+ *
+ * @param aName Name of bucket.
+ * @param aTimeSteps An array of times in milliseconds to count up to before
+ * reverting back to the default bucket. The array of times
+ * is expected to be pre-sorted in ascending order.
+ * For example, given a bucket name of 'bucket', the times:
+ * [60000, 300000, 600000]
+ * will result in the following buckets:
+ * * bucket|1m - for the first 1 minute
+ * * bucket|5m - for the following 4 minutes
+ * (until 5 minutes after the start)
+ * * bucket|10m - for the following 5 minutes
+ * (until 10 minutes after the start)
+ * * __DEFAULT__ - until a new bucket is set
+ * @param aTimeOffset Time offset, in milliseconds, from which to start
+ * counting. For example, if the first time step is 1000ms,
+ * and the time offset is 300ms, then the next time step
+ * will become active after 700ms. This affects all
+ * following time steps also, meaning they will also all be
+ * timed as though they started expiring 300ms before
+ * setExpiringBucket was called.
+ */
+ setExpiringBucket: function(aName, aTimeSteps, aTimeOffset = 0) {
+ if (aTimeSteps.length === 0) {
+ this.setBucket(null);
+ return;
+ }
+
+ if (this._bucketTimer) {
+ Timer.clearTimeout(this._bucketTimer);
+ this._bucketTimer = null;
+ }
+
+ // Make a copy of the time steps array, so we can safely modify it without
+ // modifying the original array that external code has passed to us.
+ let steps = [...aTimeSteps];
+ let msec = steps.shift();
+ let postfix = this._toTimeStr(msec);
+ this.setBucket(aName + BUCKET_SEPARATOR + postfix);
+
+ this._bucketTimer = Timer.setTimeout(() => {
+ this._bucketTimer = null;
+ this.setExpiringBucket(aName, steps, aTimeOffset + msec);
+ }, msec - aTimeOffset);
+ },
+
+ /**
+ * Formats a time interval, in milliseconds, to a minimal non-localized string
+ * representation. Format is: 'h' for hours, 'm' for minutes, 's' for seconds,
+ * 'ms' for milliseconds.
+ * Examples:
+ * 65 => 65ms
+ * 1000 => 1s
+ * 60000 => 1m
+ * 61000 => 1m01s
+ *
+ * @param aTimeMS Time in milliseconds
+ *
+ * @return Minimal string representation.
+ */
+ _toTimeStr: function(aTimeMS) {
+ let timeStr = "";
+
+ function reduce(aUnitLength, aSymbol) {
+ if (aTimeMS >= aUnitLength) {
+ let units = Math.floor(aTimeMS / aUnitLength);
+ aTimeMS = aTimeMS - (units * aUnitLength)
+ timeStr += units + aSymbol;
+ }
+ }
+
+ reduce(MS_HOUR, "h");
+ reduce(MS_MINUTE, "m");
+ reduce(MS_SECOND, "s");
+ reduce(1, "ms");
+
+ return timeStr;
+ },
+};
+
+/**
+ * Returns the id of the first ancestor of aNode that has an id. If aNode
+ * has no parent, or no ancestor has an id, returns null.
+ *
+ * @param aNode the node to find the first ID'd ancestor of
+ */
+function getIDBasedOnFirstIDedAncestor(aNode) {
+ while (!aNode.id) {
+ aNode = aNode.parentNode;
+ if (!aNode) {
+ return null;
+ }
+ }
+
+ return aNode.id;
+}
diff --git a/browser/modules/BrowserUsageTelemetry.jsm b/browser/modules/BrowserUsageTelemetry.jsm
new file mode 100644
index 000000000..39012d2ab
--- /dev/null
+++ b/browser/modules/BrowserUsageTelemetry.jsm
@@ -0,0 +1,468 @@
+/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["BrowserUsageTelemetry"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+
+// The upper bound for the count of the visited unique domain names.
+const MAX_UNIQUE_VISITED_DOMAINS = 100;
+
+// Observed topic names.
+const WINDOWS_RESTORED_TOPIC = "sessionstore-windows-restored";
+const TAB_RESTORING_TOPIC = "SSTabRestoring";
+const TELEMETRY_SUBSESSIONSPLIT_TOPIC = "internal-telemetry-after-subsession-split";
+const DOMWINDOW_OPENED_TOPIC = "domwindowopened";
+
+// Probe names.
+const MAX_TAB_COUNT_SCALAR_NAME = "browser.engagement.max_concurrent_tab_count";
+const MAX_WINDOW_COUNT_SCALAR_NAME = "browser.engagement.max_concurrent_window_count";
+const TAB_OPEN_EVENT_COUNT_SCALAR_NAME = "browser.engagement.tab_open_event_count";
+const WINDOW_OPEN_EVENT_COUNT_SCALAR_NAME = "browser.engagement.window_open_event_count";
+const UNIQUE_DOMAINS_COUNT_SCALAR_NAME = "browser.engagement.unique_domains_count";
+const TOTAL_URI_COUNT_SCALAR_NAME = "browser.engagement.total_uri_count";
+const UNFILTERED_URI_COUNT_SCALAR_NAME = "browser.engagement.unfiltered_uri_count";
+
+// A list of known search origins.
+const KNOWN_SEARCH_SOURCES = [
+ "abouthome",
+ "contextmenu",
+ "newtab",
+ "searchbar",
+ "urlbar",
+];
+
+const KNOWN_ONEOFF_SOURCES = [
+ "oneoff-urlbar",
+ "oneoff-searchbar",
+ "unknown", // Edge case: this is the searchbar (see bug 1195733 comment 7).
+];
+
+function getOpenTabsAndWinsCounts() {
+ let tabCount = 0;
+ let winCount = 0;
+
+ let browserEnum = Services.wm.getEnumerator("navigator:browser");
+ while (browserEnum.hasMoreElements()) {
+ let win = browserEnum.getNext();
+ winCount++;
+ tabCount += win.gBrowser.tabs.length;
+ }
+
+ return { tabCount, winCount };
+}
+
+function getSearchEngineId(engine) {
+ if (engine) {
+ if (engine.identifier) {
+ return engine.identifier;
+ }
+ // Due to bug 1222070, we can't directly check Services.telemetry.canRecordExtended
+ // here.
+ const extendedTelemetry = Services.prefs.getBoolPref("toolkit.telemetry.enabled");
+ if (engine.name && extendedTelemetry) {
+ // If it's a custom search engine only report the engine name
+ // if extended Telemetry is enabled.
+ return "other-" + engine.name;
+ }
+ }
+ return "other";
+}
+
+let URICountListener = {
+ // A set containing the visited domains, see bug 1271310.
+ _domainSet: new Set(),
+ // A map to keep track of the URIs loaded from the restored tabs.
+ _restoredURIsMap: new WeakMap(),
+
+ isHttpURI(uri) {
+ // Only consider http(s) schemas.
+ return uri.schemeIs("http") || uri.schemeIs("https");
+ },
+
+ addRestoredURI(browser, uri) {
+ if (!this.isHttpURI(uri)) {
+ return;
+ }
+
+ this._restoredURIsMap.set(browser, uri.spec);
+ },
+
+ onLocationChange(browser, webProgress, request, uri, flags) {
+ // Don't count this URI if it's an error page.
+ if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
+ return;
+ }
+
+ // We only care about top level loads.
+ if (!webProgress.isTopLevel) {
+ return;
+ }
+
+ // The SessionStore sets the URI of a tab first, firing onLocationChange the
+ // first time, then manages content loading using its scheduler. Once content
+ // loads, we will hit onLocationChange again.
+ // We can catch the first case by checking for null requests: be advised that
+ // this can also happen when navigating page fragments, so account for it.
+ if (!request &&
+ !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)) {
+ return;
+ }
+
+ // Track URI loads, even if they're not http(s).
+ let uriSpec = null;
+ try {
+ uriSpec = uri.spec;
+ } catch (e) {
+ // If we have troubles parsing the spec, still count this as
+ // an unfiltered URI.
+ Services.telemetry.scalarAdd(UNFILTERED_URI_COUNT_SCALAR_NAME, 1);
+ return;
+ }
+
+
+ // Don't count about:blank and similar pages, as they would artificially
+ // inflate the counts.
+ if (browser.ownerDocument.defaultView.gInitialPages.includes(uriSpec)) {
+ return;
+ }
+
+ // If the URI we're loading is in the _restoredURIsMap, then it comes from a
+ // restored tab. If so, let's skip it and remove it from the map as we want to
+ // count page refreshes.
+ if (this._restoredURIsMap.get(browser) === uriSpec) {
+ this._restoredURIsMap.delete(browser);
+ return;
+ }
+
+ // The URI wasn't from a restored tab. Count it among the unfiltered URIs.
+ // If this is an http(s) URI, this also gets counted by the "total_uri_count"
+ // probe.
+ Services.telemetry.scalarAdd(UNFILTERED_URI_COUNT_SCALAR_NAME, 1);
+
+ if (!this.isHttpURI(uri)) {
+ return;
+ }
+
+ // Update the URI counts.
+ Services.telemetry.scalarAdd(TOTAL_URI_COUNT_SCALAR_NAME, 1);
+
+ // We only want to count the unique domains up to MAX_UNIQUE_VISITED_DOMAINS.
+ if (this._domainSet.size == MAX_UNIQUE_VISITED_DOMAINS) {
+ return;
+ }
+
+ // Unique domains should be aggregated by (eTLD + 1): x.test.com and y.test.com
+ // are counted once as test.com.
+ try {
+ // Even if only considering http(s) URIs, |getBaseDomain| could still throw
+ // due to the URI containing invalid characters or the domain actually being
+ // an ipv4 or ipv6 address.
+ this._domainSet.add(Services.eTLD.getBaseDomain(uri));
+ } catch (e) {
+ return;
+ }
+
+ Services.telemetry.scalarSet(UNIQUE_DOMAINS_COUNT_SCALAR_NAME, this._domainSet.size);
+ },
+
+ /**
+ * Reset the counts. This should be called when breaking a session in Telemetry.
+ */
+ reset() {
+ this._domainSet.clear();
+ },
+
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener,
+ Ci.nsISupportsWeakReference]),
+};
+
+let BrowserUsageTelemetry = {
+ init() {
+ Services.obs.addObserver(this, WINDOWS_RESTORED_TOPIC, false);
+ },
+
+ /**
+ * Handle subsession splits in the parent process.
+ */
+ afterSubsessionSplit() {
+ // Scalars just got cleared due to a subsession split. We need to set the maximum
+ // concurrent tab and window counts so that they reflect the correct value for the
+ // new subsession.
+ const counts = getOpenTabsAndWinsCounts();
+ Services.telemetry.scalarSetMaximum(MAX_TAB_COUNT_SCALAR_NAME, counts.tabCount);
+ Services.telemetry.scalarSetMaximum(MAX_WINDOW_COUNT_SCALAR_NAME, counts.winCount);
+
+ // Reset the URI counter.
+ URICountListener.reset();
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, DOMWINDOW_OPENED_TOPIC, false);
+ Services.obs.removeObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC, false);
+ Services.obs.removeObserver(this, WINDOWS_RESTORED_TOPIC, false);
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case WINDOWS_RESTORED_TOPIC:
+ this._setupAfterRestore();
+ break;
+ case DOMWINDOW_OPENED_TOPIC:
+ this._onWindowOpen(subject);
+ break;
+ case TELEMETRY_SUBSESSIONSPLIT_TOPIC:
+ this.afterSubsessionSplit();
+ break;
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "TabOpen":
+ this._onTabOpen();
+ break;
+ case "unload":
+ this._unregisterWindow(event.target);
+ break;
+ case TAB_RESTORING_TOPIC:
+ // We're restoring a new tab from a previous or crashed session.
+ // We don't want to track the URIs from these tabs, so let
+ // |URICountListener| know about them.
+ let browser = event.target.linkedBrowser;
+ URICountListener.addRestoredURI(browser, browser.currentURI);
+ break;
+ }
+ },
+
+ /**
+ * The main entry point for recording search related Telemetry. This includes
+ * search counts and engagement measurements.
+ *
+ * Telemetry records only search counts per engine and action origin, but
+ * nothing pertaining to the search contents themselves.
+ *
+ * @param {nsISearchEngine} engine
+ * The engine handling the search.
+ * @param {String} source
+ * Where the search originated from. See KNOWN_SEARCH_SOURCES for allowed
+ * values.
+ * @param {Object} [details] Options object.
+ * @param {Boolean} [details.isOneOff=false]
+ * true if this event was generated by a one-off search.
+ * @param {Boolean} [details.isSuggestion=false]
+ * true if this event was generated by a suggested search.
+ * @param {Boolean} [details.isAlias=false]
+ * true if this event was generated by a search using an alias.
+ * @param {Object} [details.type=null]
+ * The object describing the event that triggered the search.
+ * @throws if source is not in the known sources list.
+ */
+ recordSearch(engine, source, details={}) {
+ const isOneOff = !!details.isOneOff;
+ const countId = getSearchEngineId(engine) + "." + source;
+
+ if (isOneOff) {
+ if (!KNOWN_ONEOFF_SOURCES.includes(source)) {
+ // Silently drop the error if this bogus call
+ // came from 'urlbar' or 'searchbar'. They're
+ // calling |recordSearch| twice from two different
+ // code paths because they want to record the search
+ // in SEARCH_COUNTS.
+ if (['urlbar', 'searchbar'].includes(source)) {
+ Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS").add(countId);
+ return;
+ }
+ throw new Error("Unknown source for one-off search: " + source);
+ }
+ } else {
+ if (!KNOWN_SEARCH_SOURCES.includes(source)) {
+ throw new Error("Unknown source for search: " + source);
+ }
+ Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS").add(countId);
+ }
+
+ // Dispatch the search signal to other handlers.
+ this._handleSearchAction(engine, source, details);
+ },
+
+ _recordSearch(engine, source, action = null) {
+ let scalarKey = action ? "search_" + action : "search";
+ Services.telemetry.keyedScalarAdd("browser.engagement.navigation." + source,
+ scalarKey, 1);
+ Services.telemetry.recordEvent("navigation", "search", source, action,
+ { engine: getSearchEngineId(engine) });
+ },
+
+ _handleSearchAction(engine, source, details) {
+ switch (source) {
+ case "urlbar":
+ case "oneoff-urlbar":
+ case "searchbar":
+ case "oneoff-searchbar":
+ case "unknown": // Edge case: this is the searchbar (see bug 1195733 comment 7).
+ this._handleSearchAndUrlbar(engine, source, details);
+ break;
+ case "abouthome":
+ this._recordSearch(engine, "about_home", "enter");
+ break;
+ case "newtab":
+ this._recordSearch(engine, "about_newtab", "enter");
+ break;
+ case "contextmenu":
+ this._recordSearch(engine, "contextmenu");
+ break;
+ }
+ },
+
+ /**
+ * This function handles the "urlbar", "urlbar-oneoff", "searchbar" and
+ * "searchbar-oneoff" sources.
+ */
+ _handleSearchAndUrlbar(engine, source, details) {
+ // We want "urlbar" and "urlbar-oneoff" (and similar cases) to go in the same
+ // scalar, but in a different key.
+
+ // When using one-offs in the searchbar we get an "unknown" source. See bug
+ // 1195733 comment 7 for the context. Fix-up the label here.
+ const sourceName =
+ (source === "unknown") ? "searchbar" : source.replace("oneoff-", "");
+
+ const isOneOff = !!details.isOneOff;
+ if (isOneOff) {
+ // We will receive a signal from the "urlbar"/"searchbar" even when the
+ // search came from "oneoff-urlbar". That's because both signals
+ // are propagated from search.xml. Skip it if that's the case.
+ // Moreover, we skip the "unknown" source that comes from the searchbar
+ // when performing searches from the default search engine. See bug 1195733
+ // comment 7 for context.
+ if (["urlbar", "searchbar", "unknown"].includes(source)) {
+ return;
+ }
+
+ // If that's a legit one-off search signal, record it using the relative key.
+ this._recordSearch(engine, sourceName, "oneoff");
+ return;
+ }
+
+ // The search was not a one-off. It was a search with the default search engine.
+ if (details.isSuggestion) {
+ // It came from a suggested search, so count it as such.
+ this._recordSearch(engine, sourceName, "suggestion");
+ return;
+ } else if (details.isAlias) {
+ // This one came from a search that used an alias.
+ this._recordSearch(engine, sourceName, "alias");
+ return;
+ }
+
+ // The search signal was generated by typing something and pressing enter.
+ this._recordSearch(engine, sourceName, "enter");
+ },
+
+ /**
+ * This gets called shortly after the SessionStore has finished restoring
+ * windows and tabs. It counts the open tabs and adds listeners to all the
+ * windows.
+ */
+ _setupAfterRestore() {
+ // Make sure to catch new chrome windows and subsession splits.
+ Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, false);
+ Services.obs.addObserver(this, TELEMETRY_SUBSESSIONSPLIT_TOPIC, false);
+
+ // Attach the tabopen handlers to the existing Windows.
+ let browserEnum = Services.wm.getEnumerator("navigator:browser");
+ while (browserEnum.hasMoreElements()) {
+ this._registerWindow(browserEnum.getNext());
+ }
+
+ // Get the initial tab and windows max counts.
+ const counts = getOpenTabsAndWinsCounts();
+ Services.telemetry.scalarSetMaximum(MAX_TAB_COUNT_SCALAR_NAME, counts.tabCount);
+ Services.telemetry.scalarSetMaximum(MAX_WINDOW_COUNT_SCALAR_NAME, counts.winCount);
+ },
+
+ /**
+ * Adds listeners to a single chrome window.
+ */
+ _registerWindow(win) {
+ win.addEventListener("unload", this);
+ win.addEventListener("TabOpen", this, true);
+
+ // Don't include URI and domain counts when in private mode.
+ if (PrivateBrowsingUtils.isWindowPrivate(win)) {
+ return;
+ }
+ win.gBrowser.tabContainer.addEventListener(TAB_RESTORING_TOPIC, this);
+ win.gBrowser.addTabsProgressListener(URICountListener);
+ },
+
+ /**
+ * Removes listeners from a single chrome window.
+ */
+ _unregisterWindow(win) {
+ win.removeEventListener("unload", this);
+ win.removeEventListener("TabOpen", this, true);
+
+ // Don't include URI and domain counts when in private mode.
+ if (PrivateBrowsingUtils.isWindowPrivate(win.defaultView)) {
+ return;
+ }
+ win.defaultView.gBrowser.tabContainer.removeEventListener(TAB_RESTORING_TOPIC, this);
+ win.defaultView.gBrowser.removeTabsProgressListener(URICountListener);
+ },
+
+ /**
+ * Updates the tab counts.
+ * @param {Number} [newTabCount=0] The count of the opened tabs across all windows. This
+ * is computed manually if not provided.
+ */
+ _onTabOpen(tabCount = 0) {
+ // Use the provided tab count if available. Otherwise, go on and compute it.
+ tabCount = tabCount || getOpenTabsAndWinsCounts().tabCount;
+ // Update the "tab opened" count and its maximum.
+ Services.telemetry.scalarAdd(TAB_OPEN_EVENT_COUNT_SCALAR_NAME, 1);
+ Services.telemetry.scalarSetMaximum(MAX_TAB_COUNT_SCALAR_NAME, tabCount);
+ },
+
+ /**
+ * Tracks the window count and registers the listeners for the tab count.
+ * @param{Object} win The window object.
+ */
+ _onWindowOpen(win) {
+ // Make sure to have a |nsIDOMWindow|.
+ if (!(win instanceof Ci.nsIDOMWindow)) {
+ return;
+ }
+
+ let onLoad = () => {
+ win.removeEventListener("load", onLoad, false);
+
+ // Ignore non browser windows.
+ if (win.document.documentElement.getAttribute("windowtype") != "navigator:browser") {
+ return;
+ }
+
+ this._registerWindow(win);
+ // Track the window open event and check the maximum.
+ const counts = getOpenTabsAndWinsCounts();
+ Services.telemetry.scalarAdd(WINDOW_OPEN_EVENT_COUNT_SCALAR_NAME, 1);
+ Services.telemetry.scalarSetMaximum(MAX_WINDOW_COUNT_SCALAR_NAME, counts.winCount);
+
+ // We won't receive the "TabOpen" event for the first tab within a new window.
+ // Account for that.
+ this._onTabOpen(counts.tabCount);
+ };
+ win.addEventListener("load", onLoad, false);
+ },
+};
diff --git a/browser/modules/CastingApps.jsm b/browser/modules/CastingApps.jsm
new file mode 100644
index 000000000..6f32753e8
--- /dev/null
+++ b/browser/modules/CastingApps.jsm
@@ -0,0 +1,164 @@
+// -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+this.EXPORTED_SYMBOLS = ["CastingApps"];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/SimpleServiceDiscovery.jsm");
+
+
+var CastingApps = {
+ _sendEventToVideo: function (element, data) {
+ let event = element.ownerDocument.createEvent("CustomEvent");
+ event.initCustomEvent("media-videoCasting", false, true, JSON.stringify(data));
+ element.dispatchEvent(event);
+ },
+
+ makeURI: function (url, charset, baseURI) {
+ return Services.io.newURI(url, charset, baseURI);
+ },
+
+ getVideo: function (element) {
+ if (!element) {
+ return null;
+ }
+
+ let extensions = SimpleServiceDiscovery.getSupportedExtensions();
+ let types = SimpleServiceDiscovery.getSupportedMimeTypes();
+
+ // Grab the poster attribute from the <video>
+ let posterURL = element.poster;
+
+ // First, look to see if the <video> has a src attribute
+ let sourceURL = element.src;
+
+ // If empty, try the currentSrc
+ if (!sourceURL) {
+ sourceURL = element.currentSrc;
+ }
+
+ if (sourceURL) {
+ // Use the file extension to guess the mime type
+ let sourceURI = this.makeURI(sourceURL, null, this.makeURI(element.baseURI));
+ if (this.allowableExtension(sourceURI, extensions)) {
+ return { element: element, source: sourceURI.spec, poster: posterURL, sourceURI: sourceURI};
+ }
+ }
+
+ // Next, look to see if there is a <source> child element that meets
+ // our needs
+ let sourceNodes = element.getElementsByTagName("source");
+ for (let sourceNode of sourceNodes) {
+ let sourceURI = this.makeURI(sourceNode.src, null, this.makeURI(sourceNode.baseURI));
+
+ // Using the type attribute is our ideal way to guess the mime type. Otherwise,
+ // fallback to using the file extension to guess the mime type
+ if (this.allowableMimeType(sourceNode.type, types) || this.allowableExtension(sourceURI, extensions)) {
+ return { element: element, source: sourceURI.spec, poster: posterURL, sourceURI: sourceURI, type: sourceNode.type };
+ }
+ }
+
+ return null;
+ },
+
+ sendVideoToService: function (videoElement, service) {
+ if (!service)
+ return;
+
+ let video = this.getVideo(videoElement);
+ if (!video) {
+ return;
+ }
+
+ // Make sure we have a player app for the given service
+ let app = SimpleServiceDiscovery.findAppForService(service);
+ if (!app)
+ return;
+
+ video.title = videoElement.ownerGlobal.top.document.title;
+ if (video.element) {
+ // If the video is currently playing on the device, pause it
+ if (!video.element.paused) {
+ video.element.pause();
+ }
+ }
+
+ app.stop(() => {
+ app.start(started => {
+ if (!started) {
+ Cu.reportError("CastingApps: Unable to start app");
+ return;
+ }
+
+ app.remoteMedia(remoteMedia => {
+ if (!remoteMedia) {
+ Cu.reportError("CastingApps: Failed to create remotemedia");
+ return;
+ }
+
+ this.session = {
+ service: service,
+ app: app,
+ remoteMedia: remoteMedia,
+ data: {
+ title: video.title,
+ source: video.source,
+ poster: video.poster
+ },
+ videoRef: Cu.getWeakReference(video.element)
+ };
+ }, this);
+ });
+ });
+ },
+
+ getServicesForVideo: function (videoElement) {
+ let video = this.getVideo(videoElement);
+ if (!video) {
+ return {};
+ }
+
+ let filteredServices = SimpleServiceDiscovery.services.filter(service => {
+ return this.allowableExtension(video.sourceURI, service.extensions) ||
+ this.allowableMimeType(video.type, service.types);
+ });
+
+ return filteredServices;
+ },
+
+ getServicesForMirroring: function () {
+ return SimpleServiceDiscovery.services.filter(service => service.mirror);
+ },
+
+ // RemoteMedia callback API methods
+ onRemoteMediaStart: function (remoteMedia) {
+ if (!this.session) {
+ return;
+ }
+
+ remoteMedia.load(this.session.data);
+
+ let video = this.session.videoRef.get();
+ if (video) {
+ this._sendEventToVideo(video, { active: true });
+ }
+ },
+
+ onRemoteMediaStop: function (remoteMedia) {
+ },
+
+ onRemoteMediaStatus: function (remoteMedia) {
+ },
+
+ allowableExtension: function (uri, extensions) {
+ return (uri instanceof Ci.nsIURL) && extensions.indexOf(uri.fileExtension) != -1;
+ },
+
+ allowableMimeType: function (type, types) {
+ return types.indexOf(type) != -1;
+ }
+};
diff --git a/browser/modules/ContentClick.jsm b/browser/modules/ContentClick.jsm
new file mode 100644
index 000000000..8abc32525
--- /dev/null
+++ b/browser/modules/ContentClick.jsm
@@ -0,0 +1,97 @@
+/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = [ "ContentClick" ];
+
+Cu.import("resource:///modules/PlacesUIUtils.jsm");
+Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+var ContentClick = {
+ init: function() {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
+ mm.addMessageListener("Content:Click", this);
+ },
+
+ receiveMessage: function (message) {
+ switch (message.name) {
+ case "Content:Click":
+ this.contentAreaClick(message.json, message.target)
+ break;
+ }
+ },
+
+ contentAreaClick: function (json, browser) {
+ // This is heavily based on contentAreaClick from browser.js (Bug 903016)
+ // The json is set up in a way to look like an Event.
+ let window = browser.ownerGlobal;
+
+ if (!json.href) {
+ // Might be middle mouse navigation.
+ if (Services.prefs.getBoolPref("middlemouse.contentLoadURL") &&
+ !Services.prefs.getBoolPref("general.autoScroll")) {
+ window.middleMousePaste(json);
+ }
+ return;
+ }
+
+ if (json.bookmark) {
+ // This is the Opera convention for a special link that, when clicked,
+ // allows to add a sidebar panel. The link's title attribute contains
+ // the title that should be used for the sidebar panel.
+ PlacesUIUtils.showBookmarkDialog({ action: "add"
+ , type: "bookmark"
+ , uri: Services.io.newURI(json.href, null, null)
+ , title: json.title
+ , loadBookmarkInSidebar: true
+ , hiddenRows: [ "description"
+ , "location"
+ , "keyword" ]
+ }, window);
+ return;
+ }
+
+ // Note: We don't need the sidebar code here.
+
+ // Mark the page as a user followed link. This is done so that history can
+ // distinguish automatic embed visits from user activated ones. For example
+ // pages loaded in frames are embed visits and lost with the session, while
+ // visits across frames should be preserved.
+ try {
+ if (!PrivateBrowsingUtils.isWindowPrivate(window))
+ PlacesUIUtils.markPageAsFollowedLink(json.href);
+ } catch (ex) { /* Skip invalid URIs. */ }
+
+ // This part is based on handleLinkClick.
+ var where = window.whereToOpenLink(json);
+ if (where == "current")
+ return;
+
+ // Todo(903022): code for where == save
+
+ let params = {
+ charset: browser.characterSet,
+ referrerURI: browser.documentURI,
+ referrerPolicy: json.referrerPolicy,
+ noReferrer: json.noReferrer,
+ allowMixedContent: json.allowMixedContent,
+ isContentWindowPrivate: json.isContentWindowPrivate,
+ originPrincipal: json.originPrincipal,
+ };
+
+ // The new tab/window must use the same userContextId.
+ if (json.originAttributes.userContextId) {
+ params.userContextId = json.originAttributes.userContextId;
+ }
+
+ window.openLinkIn(json.href, where, params);
+ }
+};
diff --git a/browser/modules/ContentCrashHandlers.jsm b/browser/modules/ContentCrashHandlers.jsm
new file mode 100644
index 000000000..2f755d142
--- /dev/null
+++ b/browser/modules/ContentCrashHandlers.jsm
@@ -0,0 +1,1035 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+var Cr = Components.results;
+
+this.EXPORTED_SYMBOLS = [ "TabCrashHandler",
+ "PluginCrashReporter",
+ "UnsubmittedCrashHandler" ];
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "CrashSubmit",
+ "resource://gre/modules/CrashSubmit.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
+ "resource://gre/modules/AppConstants.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "RemotePages",
+ "resource://gre/modules/RemotePageManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "SessionStore",
+ "resource:///modules/sessionstore/SessionStore.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
+ "resource:///modules/RecentWindow.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
+ "resource://gre/modules/PluralForm.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "gNavigatorBundle", function() {
+ const url = "chrome://browser/locale/browser.properties";
+ return Services.strings.createBundle(url);
+});
+
+// We don't process crash reports older than 28 days, so don't bother
+// submitting them
+const PENDING_CRASH_REPORT_DAYS = 28;
+const DAY = 24 * 60 * 60 * 1000; // milliseconds
+const DAYS_TO_SUPPRESS = 30;
+const MAX_UNSEEN_CRASHED_CHILD_IDS = 20;
+
+this.TabCrashHandler = {
+ _crashedTabCount: 0,
+ childMap: new Map(),
+ browserMap: new WeakMap(),
+ unseenCrashedChildIDs: [],
+ crashedBrowserQueues: new Map(),
+
+ get prefs() {
+ delete this.prefs;
+ return this.prefs = Services.prefs.getBranch("browser.tabs.crashReporting.");
+ },
+
+ init: function () {
+ if (this.initialized)
+ return;
+ this.initialized = true;
+
+ Services.obs.addObserver(this, "ipc:content-shutdown", false);
+ Services.obs.addObserver(this, "oop-frameloader-crashed", false);
+
+ this.pageListener = new RemotePages("about:tabcrashed");
+ // LOAD_BACKGROUND pages don't fire load events, so the about:tabcrashed
+ // content will fire up its own message when its initial scripts have
+ // finished running.
+ this.pageListener.addMessageListener("Load", this.receiveMessage.bind(this));
+ this.pageListener.addMessageListener("RemotePage:Unload", this.receiveMessage.bind(this));
+ this.pageListener.addMessageListener("closeTab", this.receiveMessage.bind(this));
+ this.pageListener.addMessageListener("restoreTab", this.receiveMessage.bind(this));
+ this.pageListener.addMessageListener("restoreAll", this.receiveMessage.bind(this));
+ },
+
+ observe: function (aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "ipc:content-shutdown": {
+ aSubject.QueryInterface(Ci.nsIPropertyBag2);
+
+ if (!aSubject.get("abnormal")) {
+ return;
+ }
+
+ let childID = aSubject.get("childID");
+ let dumpID = aSubject.get("dumpID");
+
+ if (!dumpID) {
+ Services.telemetry
+ .getHistogramById("FX_CONTENT_CRASH_DUMP_UNAVAILABLE")
+ .add(1);
+ } else if (AppConstants.MOZ_CRASHREPORTER) {
+ this.childMap.set(childID, dumpID);
+ }
+
+ if (!this.flushCrashedBrowserQueue(childID)) {
+ this.unseenCrashedChildIDs.push(childID);
+ // The elements in unseenCrashedChildIDs will only be removed if
+ // the tab crash page is shown. However, ipc:content-shutdown might
+ // be fired for processes for which we'll never show the tab crash
+ // page - for example, the thumbnailing process. Another case to
+ // consider is if the user is configured to submit backlogged crash
+ // reports automatically, and a background tab crashes. In that case,
+ // we will never show the tab crash page, and never remove the element
+ // from the list.
+ //
+ // Instead of trying to account for all of those cases, we prevent
+ // this list from getting too large by putting a reasonable upper
+ // limit on how many childIDs we track. It's unlikely that this
+ // array would ever get so large as to be unwieldy (that'd be a lot
+ // or crashes!), but a leak is a leak.
+ if (this.unseenCrashedChildIDs.length > MAX_UNSEEN_CRASHED_CHILD_IDS) {
+ this.unseenCrashedChildIDs.shift();
+ }
+ }
+
+ // check for environment affecting crash reporting
+ let env = Cc["@mozilla.org/process/environment;1"]
+ .getService(Ci.nsIEnvironment);
+ let shutdown = env.exists("MOZ_CRASHREPORTER_SHUTDOWN");
+
+ if (shutdown) {
+ Services.startup.quit(Ci.nsIAppStartup.eForceQuit);
+ }
+
+ break;
+ }
+ case "oop-frameloader-crashed": {
+ aSubject.QueryInterface(Ci.nsIFrameLoader);
+
+ let browser = aSubject.ownerElement;
+ if (!browser) {
+ return;
+ }
+
+ this.browserMap.set(browser.permanentKey, aSubject.childID);
+ break;
+ }
+ }
+ },
+
+ receiveMessage: function(message) {
+ let browser = message.target.browser;
+ let gBrowser = browser.ownerGlobal.gBrowser;
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ switch (message.name) {
+ case "Load": {
+ this.onAboutTabCrashedLoad(message);
+ break;
+ }
+
+ case "RemotePage:Unload": {
+ this.onAboutTabCrashedUnload(message);
+ break;
+ }
+
+ case "closeTab": {
+ this.maybeSendCrashReport(message);
+ gBrowser.removeTab(tab, { animate: true });
+ break;
+ }
+
+ case "restoreTab": {
+ this.maybeSendCrashReport(message);
+ SessionStore.reviveCrashedTab(tab);
+ break;
+ }
+
+ case "restoreAll": {
+ this.maybeSendCrashReport(message);
+ SessionStore.reviveAllCrashedTabs();
+ break;
+ }
+ }
+ },
+
+ /**
+ * This should be called once a content process has finished
+ * shutting down abnormally. Any tabbrowser browsers that were
+ * selected at the time of the crash will then be sent to
+ * the crashed tab page.
+ *
+ * @param childID (int)
+ * The childID of the content process that just crashed.
+ * @returns boolean
+ * True if one or more browsers were sent to the tab crashed
+ * page.
+ */
+ flushCrashedBrowserQueue(childID) {
+ let browserQueue = this.crashedBrowserQueues.get(childID);
+ if (!browserQueue) {
+ return false;
+ }
+
+ this.crashedBrowserQueues.delete(childID);
+
+ let sentBrowser = false;
+ for (let weakBrowser of browserQueue) {
+ let browser = weakBrowser.get();
+ if (browser) {
+ this.sendToTabCrashedPage(browser);
+ sentBrowser = true;
+ }
+ }
+
+ return sentBrowser;
+ },
+
+ /**
+ * Called by a tabbrowser when it notices that its selected browser
+ * has crashed. This will queue the browser to show the tab crash
+ * page once the content process has finished tearing down.
+ *
+ * @param browser (<xul:browser>)
+ * The selected browser that just crashed.
+ */
+ onSelectedBrowserCrash(browser) {
+ if (!browser.isRemoteBrowser) {
+ Cu.reportError("Selected crashed browser is not remote.")
+ return;
+ }
+ if (!browser.frameLoader) {
+ Cu.reportError("Selected crashed browser has no frameloader.");
+ return;
+ }
+
+ let childID = browser.frameLoader.childID;
+ let browserQueue = this.crashedBrowserQueues.get(childID);
+ if (!browserQueue) {
+ browserQueue = [];
+ this.crashedBrowserQueues.set(childID, browserQueue);
+ }
+ // It's probably unnecessary to store this browser as a
+ // weak reference, since the content process should complete
+ // its teardown in the same tick of the event loop, and then
+ // this queue will be flushed. The weak reference is to avoid
+ // leaking browsers in case anything goes wrong during this
+ // teardown process.
+ browserQueue.push(Cu.getWeakReference(browser));
+ },
+
+ /**
+ * This method is exposed for SessionStore to call if the user selects
+ * a tab which will restore on demand. It's possible that the tab
+ * is in this state because it recently crashed. If that's the case, then
+ * it's also possible that the user has not seen the tab crash page for
+ * that particular crash, in which case, we might show it to them instead
+ * of restoring the tab.
+ *
+ * @param browser (<xul:browser>)
+ * A browser from a browser tab that the user has just selected
+ * to restore on demand.
+ * @returns (boolean)
+ * True if TabCrashHandler will send the user to the tab crash
+ * page instead.
+ */
+ willShowCrashedTab(browser) {
+ let childID = this.browserMap.get(browser.permanentKey);
+ // We will only show the tab crash page if:
+ // 1) We are aware that this browser crashed
+ // 2) We know we've never shown the tab crash page for the
+ // crash yet
+ // 3) The user is not configured to automatically submit backlogged
+ // crash reports. If they are, we'll send the crash report
+ // immediately.
+ if (childID &&
+ this.unseenCrashedChildIDs.indexOf(childID) != -1) {
+ if (UnsubmittedCrashHandler.autoSubmit) {
+ let dumpID = this.childMap.get(childID);
+ if (dumpID) {
+ UnsubmittedCrashHandler.submitReports([dumpID]);
+ }
+ } else {
+ this.sendToTabCrashedPage(browser);
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * We show a special page to users when a normal browser tab has crashed.
+ * This method should be called to send a browser to that page once the
+ * process has completely closed.
+ *
+ * @param browser (<xul:browser>)
+ * The browser that has recently crashed.
+ */
+ sendToTabCrashedPage(browser) {
+ let title = browser.contentTitle;
+ let uri = browser.currentURI;
+ let gBrowser = browser.ownerGlobal.gBrowser;
+ let tab = gBrowser.getTabForBrowser(browser);
+ // The tab crashed page is non-remote by default.
+ gBrowser.updateBrowserRemoteness(browser, false);
+
+ browser.setAttribute("crashedPageTitle", title);
+ browser.docShell.displayLoadError(Cr.NS_ERROR_CONTENT_CRASHED, uri, null);
+ browser.removeAttribute("crashedPageTitle");
+ tab.setAttribute("crashed", true);
+ },
+
+ /**
+ * Submits a crash report from about:tabcrashed, if the crash
+ * reporter is enabled and a crash report can be found.
+ *
+ * @param aBrowser
+ * The <xul:browser> that the report was sent from.
+ * @param aFormData
+ * An Object with the following properties:
+ *
+ * includeURL (bool):
+ * Whether to include the URL that the user was on
+ * in the crashed tab before the crash occurred.
+ * URL (String)
+ * The URL that the user was on in the crashed tab
+ * before the crash occurred.
+ * emailMe (bool):
+ * Whether or not to include the user's email address
+ * in the crash report.
+ * email (String):
+ * The email address of the user.
+ * comments (String):
+ * Any additional comments from the user.
+ *
+ * Note that it is expected that all properties are set,
+ * even if they are empty.
+ */
+ maybeSendCrashReport(message) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+
+ if (!message.data.hasReport) {
+ // There was no report, so nothing to do.
+ return;
+ }
+
+ let browser = message.target.browser;
+
+ if (message.data.autoSubmit) {
+ // The user has opted in to autosubmitted backlogged
+ // crash reports in the future.
+ UnsubmittedCrashHandler.autoSubmit = true;
+ }
+
+ let childID = this.browserMap.get(browser.permanentKey);
+ let dumpID = this.childMap.get(childID);
+ if (!dumpID) {
+ return;
+ }
+
+ if (!message.data.sendReport) {
+ Services.telemetry.getHistogramById("FX_CONTENT_CRASH_NOT_SUBMITTED").add(1);
+ this.prefs.setBoolPref("sendReport", false);
+ return;
+ }
+
+ let {
+ includeURL,
+ comments,
+ email,
+ emailMe,
+ URL,
+ } = message.data;
+
+ let extraExtraKeyVals = {
+ "Comments": comments,
+ "Email": email,
+ "URL": URL,
+ };
+
+ // For the entries in extraExtraKeyVals, we only want to submit the
+ // extra data values where they are not the empty string.
+ for (let key in extraExtraKeyVals) {
+ let val = extraExtraKeyVals[key].trim();
+ if (!val) {
+ delete extraExtraKeyVals[key];
+ }
+ }
+
+ // URL is special, since it's already been written to extra data by
+ // default. In order to make sure we don't send it, we overwrite it
+ // with the empty string.
+ if (!includeURL) {
+ extraExtraKeyVals["URL"] = "";
+ }
+
+ CrashSubmit.submit(dumpID, {
+ recordSubmission: true,
+ extraExtraKeyVals,
+ }).then(null, Cu.reportError);
+
+ this.prefs.setBoolPref("sendReport", true);
+ this.prefs.setBoolPref("includeURL", includeURL);
+ this.prefs.setBoolPref("emailMe", emailMe);
+ if (emailMe) {
+ this.prefs.setCharPref("email", email);
+ } else {
+ this.prefs.setCharPref("email", "");
+ }
+
+ this.childMap.set(childID, null); // Avoid resubmission.
+ this.removeSubmitCheckboxesForSameCrash(childID);
+ },
+
+ removeSubmitCheckboxesForSameCrash: function(childID) {
+ let enumerator = Services.wm.getEnumerator("navigator:browser");
+ while (enumerator.hasMoreElements()) {
+ let window = enumerator.getNext();
+ if (!window.gMultiProcessBrowser)
+ continue;
+
+ for (let browser of window.gBrowser.browsers) {
+ if (browser.isRemoteBrowser)
+ continue;
+
+ let doc = browser.contentDocument;
+ if (!doc.documentURI.startsWith("about:tabcrashed"))
+ continue;
+
+ if (this.browserMap.get(browser.permanentKey) == childID) {
+ this.browserMap.delete(browser.permanentKey);
+ let ports = this.pageListener.portsForBrowser(browser);
+ if (ports.length) {
+ // For about:tabcrashed, we don't expect subframes. We can
+ // assume sending to the first port is sufficient.
+ ports[0].sendAsyncMessage("CrashReportSent");
+ }
+ }
+ }
+ }
+ },
+
+ onAboutTabCrashedLoad: function (message) {
+ this._crashedTabCount++;
+
+ // Broadcast to all about:tabcrashed pages a count of
+ // how many about:tabcrashed pages exist, so that they
+ // can decide whether or not to display the "Restore All
+ // Crashed Tabs" button.
+ this.pageListener.sendAsyncMessage("UpdateCount", {
+ count: this._crashedTabCount,
+ });
+
+ let browser = message.target.browser;
+
+ let childID = this.browserMap.get(browser.permanentKey);
+ let index = this.unseenCrashedChildIDs.indexOf(childID);
+ if (index != -1) {
+ this.unseenCrashedChildIDs.splice(index, 1);
+ }
+
+ let dumpID = this.getDumpID(browser);
+ if (!dumpID) {
+ message.target.sendAsyncMessage("SetCrashReportAvailable", {
+ hasReport: false,
+ });
+ return;
+ }
+
+ let requestAutoSubmit = !UnsubmittedCrashHandler.autoSubmit;
+ let requestEmail = this.prefs.getBoolPref("requestEmail");
+ let sendReport = this.prefs.getBoolPref("sendReport");
+ let includeURL = this.prefs.getBoolPref("includeURL");
+ let emailMe = this.prefs.getBoolPref("emailMe");
+
+ let data = {
+ hasReport: true,
+ sendReport,
+ includeURL,
+ emailMe,
+ requestAutoSubmit,
+ requestEmail,
+ };
+
+ if (emailMe) {
+ data.email = this.prefs.getCharPref("email", "");
+ }
+
+ // Make sure to only count once even if there are multiple windows
+ // that will all show about:tabcrashed.
+ if (this._crashedTabCount == 1) {
+ Services.telemetry.getHistogramById("FX_CONTENT_CRASH_PRESENTED").add(1);
+ }
+
+ message.target.sendAsyncMessage("SetCrashReportAvailable", data);
+ },
+
+ onAboutTabCrashedUnload(message) {
+ if (!this._crashedTabCount) {
+ Cu.reportError("Can not decrement crashed tab count to below 0");
+ return;
+ }
+ this._crashedTabCount--;
+
+ // Broadcast to all about:tabcrashed pages a count of
+ // how many about:tabcrashed pages exist, so that they
+ // can decide whether or not to display the "Restore All
+ // Crashed Tabs" button.
+ this.pageListener.sendAsyncMessage("UpdateCount", {
+ count: this._crashedTabCount,
+ });
+
+ let browser = message.target.browser;
+ let childID = this.browserMap.get(browser.permanentKey);
+
+ // Make sure to only count once even if there are multiple windows
+ // that will all show about:tabcrashed.
+ if (this._crashedTabCount == 0 && childID) {
+ Services.telemetry.getHistogramById("FX_CONTENT_CRASH_NOT_SUBMITTED").add(1);
+ }
+ },
+
+ /**
+ * For some <xul:browser>, return a crash report dump ID for that browser
+ * if we have been informed of one. Otherwise, return null.
+ *
+ * @param browser (<xul:browser)
+ * The browser to try to get the dump ID for
+ * @returns dumpID (String)
+ */
+ getDumpID(browser) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return null;
+ }
+
+ return this.childMap.get(this.browserMap.get(browser.permanentKey));
+ },
+}
+
+/**
+ * This component is responsible for scanning the pending
+ * crash report directory for reports, and (if enabled), to
+ * prompt the user to submit those reports. It might also
+ * submit those reports automatically without prompting if
+ * the user has opted in.
+ */
+this.UnsubmittedCrashHandler = {
+ get prefs() {
+ delete this.prefs;
+ return this.prefs =
+ Services.prefs.getBranch("browser.crashReports.unsubmittedCheck.");
+ },
+
+ get enabled() {
+ return this.prefs.getBoolPref("enabled");
+ },
+
+ // showingNotification is set to true once a notification
+ // is successfully shown, and then set back to false if
+ // the notification is dismissed by an action by the user.
+ showingNotification: false,
+ // suppressed is true if we've determined that we've shown
+ // the notification too many times across too many days without
+ // user interaction, so we're suppressing the notification for
+ // some number of days. See the documentation for
+ // shouldShowPendingSubmissionsNotification().
+ suppressed: false,
+
+ init() {
+ if (this.initialized) {
+ return;
+ }
+
+ this.initialized = true;
+
+ // UnsubmittedCrashHandler can be initialized but still be disabled.
+ // This is intentional, as this makes simulating UnsubmittedCrashHandler's
+ // reactions to browser startup and shutdown easier in test automation.
+ //
+ // UnsubmittedCrashHandler, when initialized but not enabled, is inert.
+ if (this.enabled) {
+ if (this.prefs.prefHasUserValue("suppressUntilDate")) {
+ if (this.prefs.getCharPref("suppressUntilDate") > this.dateString()) {
+ // We'll be suppressing any notifications until after suppressedDate,
+ // so there's no need to do anything more.
+ this.suppressed = true;
+ return;
+ }
+
+ // We're done suppressing, so we don't need this pref anymore.
+ this.prefs.clearUserPref("suppressUntilDate");
+ }
+
+ Services.obs.addObserver(this, "browser-delayed-startup-finished",
+ false);
+ Services.obs.addObserver(this, "profile-before-change",
+ false);
+ }
+ },
+
+ uninit() {
+ if (!this.initialized) {
+ return;
+ }
+
+ this.initialized = false;
+
+ if (!this.enabled) {
+ return;
+ }
+
+ if (this.suppressed) {
+ this.suppressed = false;
+ // No need to do any more clean-up, since we were suppressed.
+ return;
+ }
+
+ if (this.showingNotification) {
+ this.prefs.setBoolPref("shutdownWhileShowing", true);
+ this.showingNotification = false;
+ }
+
+ try {
+ Services.obs.removeObserver(this, "browser-delayed-startup-finished");
+ } catch (e) {
+ // The browser-delayed-startup-finished observer might have already
+ // fired and removed itself, so if this fails, it's okay.
+ if (e.result != Cr.NS_ERROR_FAILURE) {
+ throw e;
+ }
+ }
+
+ Services.obs.removeObserver(this, "profile-before-change");
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "browser-delayed-startup-finished": {
+ Services.obs.removeObserver(this, topic);
+ this.checkForUnsubmittedCrashReports();
+ break;
+ }
+ case "profile-before-change": {
+ this.uninit();
+ break;
+ }
+ }
+ },
+
+ /**
+ * Scans the profile directory for unsubmitted crash reports
+ * within the past PENDING_CRASH_REPORT_DAYS days. If it
+ * finds any, it will, if necessary, attempt to open a notification
+ * bar to prompt the user to submit them.
+ *
+ * @returns Promise
+ * Resolves with the <xul:notification> after it tries to
+ * show a notification on the most recent browser window.
+ * If a notification cannot be shown, will resolve with null.
+ */
+ checkForUnsubmittedCrashReports: Task.async(function*() {
+ let dateLimit = new Date();
+ dateLimit.setDate(dateLimit.getDate() - PENDING_CRASH_REPORT_DAYS);
+
+ let reportIDs = [];
+ try {
+ reportIDs = yield CrashSubmit.pendingIDsAsync(dateLimit);
+ } catch (e) {
+ Cu.reportError(e);
+ return null;
+ }
+
+ if (reportIDs.length) {
+ if (this.autoSubmit) {
+ this.submitReports(reportIDs);
+ } else if (this.shouldShowPendingSubmissionsNotification()) {
+ return this.showPendingSubmissionsNotification(reportIDs);
+ }
+ }
+ return null;
+ }),
+
+ /**
+ * Returns true if the notification should be shown.
+ * shouldShowPendingSubmissionsNotification makes this decision
+ * by looking at whether or not the user has seen the notification
+ * over several days without ever interacting with it. If this occurs
+ * too many times, we suppress the notification for DAYS_TO_SUPPRESS
+ * days.
+ *
+ * @returns bool
+ */
+ shouldShowPendingSubmissionsNotification() {
+ if (!this.prefs.prefHasUserValue("shutdownWhileShowing")) {
+ return true;
+ }
+
+ let shutdownWhileShowing = this.prefs.getBoolPref("shutdownWhileShowing");
+ this.prefs.clearUserPref("shutdownWhileShowing");
+
+ if (!this.prefs.prefHasUserValue("lastShownDate")) {
+ // This isn't expected, but we're being defensive here. We'll
+ // opt for showing the notification in this case.
+ return true;
+ }
+
+ let lastShownDate = this.prefs.getCharPref("lastShownDate");
+ if (this.dateString() > lastShownDate && shutdownWhileShowing) {
+ // We're on a newer day then when we last showed the
+ // notification without closing it. We don't want to do
+ // this too many times, so we'll decrement a counter for
+ // this situation. Too many of these, and we'll assume the
+ // user doesn't know or care about unsubmitted notifications,
+ // and we'll suppress the notification for a while.
+ let chances = this.prefs.getIntPref("chancesUntilSuppress");
+ if (--chances < 0) {
+ // We're out of chances!
+ this.prefs.clearUserPref("chancesUntilSuppress");
+ // We'll suppress for DAYS_TO_SUPPRESS days.
+ let suppressUntil =
+ this.dateString(new Date(Date.now() + (DAY * DAYS_TO_SUPPRESS)));
+ this.prefs.setCharPref("suppressUntilDate", suppressUntil);
+ return false;
+ }
+ this.prefs.setIntPref("chancesUntilSuppress", chances);
+ }
+
+ return true;
+ },
+
+ /**
+ * Given an array of unsubmitted crash report IDs, try to open
+ * up a notification asking the user to submit them.
+ *
+ * @param reportIDs (Array<string>)
+ * The Array of report IDs to offer the user to send.
+ * @returns The <xul:notification> if one is shown. null otherwise.
+ */
+ showPendingSubmissionsNotification(reportIDs) {
+ let count = reportIDs.length;
+ if (!count) {
+ return null;
+ }
+
+ let messageTemplate =
+ gNavigatorBundle.GetStringFromName("pendingCrashReports2.label");
+
+ let message = PluralForm.get(count, messageTemplate).replace("#1", count);
+
+ let notification = this.show({
+ notificationID: "pending-crash-reports",
+ message,
+ reportIDs,
+ onAction: () => {
+ this.showingNotification = false;
+ },
+ });
+
+ if (notification) {
+ this.showingNotification = true;
+ this.prefs.setCharPref("lastShownDate", this.dateString());
+ }
+
+ return notification;
+ },
+
+ /**
+ * Returns a string representation of a Date in the format
+ * YYYYMMDD.
+ *
+ * @param someDate (Date, optional)
+ * The Date to convert to the string. If not provided,
+ * defaults to today's date.
+ * @returns String
+ */
+ dateString(someDate = new Date()) {
+ let year = String(someDate.getFullYear()).padStart(4, "0");
+ let month = String(someDate.getMonth() + 1).padStart(2, "0");
+ let day = String(someDate.getDate()).padStart(2, "0");
+ return year + month + day;
+ },
+
+ /**
+ * Attempts to show a notification bar to the user in the most
+ * recent browser window asking them to submit some crash report
+ * IDs. If a notification cannot be shown (for example, there
+ * is no browser window), this method exits silently.
+ *
+ * The notification will allow the user to submit their crash
+ * reports. If the user dismissed the notification, the crash
+ * reports will be marked to be ignored (though they can
+ * still be manually submitted via about:crashes).
+ *
+ * @param JS Object
+ * An Object with the following properties:
+ *
+ * notificationID (string)
+ * The ID for the notification to be opened.
+ *
+ * message (string)
+ * The message to be displayed in the notification.
+ *
+ * reportIDs (Array<string>)
+ * The array of report IDs to offer to the user.
+ *
+ * onAction (function, optional)
+ * A callback to fire once the user performs an
+ * action on the notification bar (this includes
+ * dismissing the notification).
+ *
+ * @returns The <xul:notification> if one is shown. null otherwise.
+ */
+ show({ notificationID, message, reportIDs, onAction }) {
+ let chromeWin = RecentWindow.getMostRecentBrowserWindow();
+ if (!chromeWin) {
+ // Can't show a notification in this case. We'll hopefully
+ // get another opportunity to have the user submit their
+ // crash reports later.
+ return null;
+ }
+
+ let nb = chromeWin.document.getElementById("global-notificationbox");
+ let notification = nb.getNotificationWithValue(notificationID);
+ if (notification) {
+ return null;
+ }
+
+ let buttons = [{
+ label: gNavigatorBundle.GetStringFromName("pendingCrashReports.send"),
+ callback: () => {
+ this.submitReports(reportIDs);
+ if (onAction) {
+ onAction();
+ }
+ },
+ },
+ {
+ label: gNavigatorBundle.GetStringFromName("pendingCrashReports.alwaysSend"),
+ callback: () => {
+ this.autoSubmit = true;
+ this.submitReports(reportIDs);
+ if (onAction) {
+ onAction();
+ }
+ },
+ },
+ {
+ label: gNavigatorBundle.GetStringFromName("pendingCrashReports.viewAll"),
+ callback: function() {
+ chromeWin.openUILinkIn("about:crashes", "tab");
+ return true;
+ },
+ }];
+
+ let eventCallback = (eventType) => {
+ if (eventType == "dismissed") {
+ // The user intentionally dismissed the notification,
+ // which we interpret as meaning that they don't care
+ // to submit the reports. We'll ignore these particular
+ // reports going forward.
+ reportIDs.forEach(function(reportID) {
+ CrashSubmit.ignore(reportID);
+ });
+ if (onAction) {
+ onAction();
+ }
+ }
+ };
+
+ return nb.appendNotification(message, notificationID,
+ "chrome://browser/skin/tab-crashed.svg",
+ nb.PRIORITY_INFO_HIGH, buttons,
+ eventCallback);
+ },
+
+ get autoSubmit() {
+ return Services.prefs
+ .getBoolPref("browser.crashReports.unsubmittedCheck.autoSubmit2");
+ },
+
+ set autoSubmit(val) {
+ Services.prefs.setBoolPref("browser.crashReports.unsubmittedCheck.autoSubmit2",
+ val);
+ },
+
+ /**
+ * Attempt to submit reports to the crash report server. Each
+ * report will have the "SubmittedFromInfobar" extra key set
+ * to true.
+ *
+ * @param reportIDs (Array<string>)
+ * The array of reportIDs to submit.
+ */
+ submitReports(reportIDs) {
+ for (let reportID of reportIDs) {
+ CrashSubmit.submit(reportID, {
+ extraExtraKeyVals: {
+ "SubmittedFromInfobar": true,
+ },
+ });
+ }
+ },
+};
+
+this.PluginCrashReporter = {
+ /**
+ * Makes the PluginCrashReporter ready to hear about and
+ * submit crash reports.
+ */
+ init() {
+ if (this.initialized) {
+ return;
+ }
+
+ this.initialized = true;
+ this.crashReports = new Map();
+
+ Services.obs.addObserver(this, "plugin-crashed", false);
+ Services.obs.addObserver(this, "gmp-plugin-crash", false);
+ Services.obs.addObserver(this, "profile-after-change", false);
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "plugin-crashed", false);
+ Services.obs.removeObserver(this, "gmp-plugin-crash", false);
+ Services.obs.removeObserver(this, "profile-after-change", false);
+ this.initialized = false;
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "plugin-crashed": {
+ let propertyBag = subject;
+ if (!(propertyBag instanceof Ci.nsIPropertyBag2) ||
+ !(propertyBag instanceof Ci.nsIWritablePropertyBag2) ||
+ !propertyBag.hasKey("runID") ||
+ !propertyBag.hasKey("pluginDumpID")) {
+ Cu.reportError("PluginCrashReporter can not read plugin information.");
+ return;
+ }
+
+ let runID = propertyBag.getPropertyAsUint32("runID");
+ let pluginDumpID = propertyBag.getPropertyAsAString("pluginDumpID");
+ let browserDumpID = propertyBag.getPropertyAsAString("browserDumpID");
+ if (pluginDumpID) {
+ this.crashReports.set(runID, { pluginDumpID, browserDumpID });
+ }
+ break;
+ }
+ case "gmp-plugin-crash": {
+ let propertyBag = subject;
+ if (!(propertyBag instanceof Ci.nsIWritablePropertyBag2) ||
+ !propertyBag.hasKey("pluginID") ||
+ !propertyBag.hasKey("pluginDumpID") ||
+ !propertyBag.hasKey("pluginName")) {
+ Cu.reportError("PluginCrashReporter can not read plugin information.");
+ return;
+ }
+
+ let pluginID = propertyBag.getPropertyAsUint32("pluginID");
+ let pluginDumpID = propertyBag.getPropertyAsAString("pluginDumpID");
+ if (pluginDumpID) {
+ this.crashReports.set(pluginID, { pluginDumpID });
+ }
+
+ // Only the parent process gets the gmp-plugin-crash observer
+ // notification, so we need to inform any content processes that
+ // the GMP has crashed.
+ if (Cc["@mozilla.org/parentprocessmessagemanager;1"]) {
+ let pluginName = propertyBag.getPropertyAsAString("pluginName");
+ let mm = Cc["@mozilla.org/parentprocessmessagemanager;1"]
+ .getService(Ci.nsIMessageListenerManager);
+ mm.broadcastAsyncMessage("gmp-plugin-crash",
+ { pluginName, pluginID });
+ }
+ break;
+ }
+ case "profile-after-change":
+ this.uninit();
+ break;
+ }
+ },
+
+ /**
+ * Submit a crash report for a crashed NPAPI plugin.
+ *
+ * @param runID
+ * The runID of the plugin that crashed. A run ID is a unique
+ * identifier for a particular run of a plugin process - and is
+ * analogous to a process ID (though it is managed by Gecko instead
+ * of the operating system).
+ * @param keyVals
+ * An object whose key-value pairs will be merged
+ * with the ".extra" file submitted with the report.
+ * The properties of htis object will override properties
+ * of the same name in the .extra file.
+ */
+ submitCrashReport(runID, keyVals) {
+ if (!this.crashReports.has(runID)) {
+ Cu.reportError(`Could not find plugin dump IDs for run ID ${runID}.` +
+ `It is possible that a report was already submitted.`);
+ return;
+ }
+
+ keyVals = keyVals || {};
+ let { pluginDumpID, browserDumpID } = this.crashReports.get(runID);
+
+ let submissionPromise = CrashSubmit.submit(pluginDumpID, {
+ recordSubmission: true,
+ extraExtraKeyVals: keyVals,
+ });
+
+ if (browserDumpID)
+ CrashSubmit.submit(browserDumpID);
+
+ this.broadcastState(runID, "submitting");
+
+ submissionPromise.then(() => {
+ this.broadcastState(runID, "success");
+ }, () => {
+ this.broadcastState(runID, "failed");
+ });
+
+ this.crashReports.delete(runID);
+ },
+
+ broadcastState(runID, state) {
+ let enumerator = Services.wm.getEnumerator("navigator:browser");
+ while (enumerator.hasMoreElements()) {
+ let window = enumerator.getNext();
+ let mm = window.messageManager;
+ mm.broadcastAsyncMessage("BrowserPlugins:CrashReportSubmitted",
+ { runID, state });
+ }
+ },
+
+ hasCrashReport(runID) {
+ return this.crashReports.has(runID);
+ },
+};
diff --git a/browser/modules/ContentLinkHandler.jsm b/browser/modules/ContentLinkHandler.jsm
new file mode 100644
index 000000000..443cae2da
--- /dev/null
+++ b/browser/modules/ContentLinkHandler.jsm
@@ -0,0 +1,147 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = [ "ContentLinkHandler" ];
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/NetUtil.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Feeds",
+ "resource:///modules/Feeds.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
+ "resource://gre/modules/BrowserUtils.jsm");
+
+const SIZES_TELEMETRY_ENUM = {
+ NO_SIZES: 0,
+ ANY: 1,
+ DIMENSION: 2,
+ INVALID: 3,
+};
+
+this.ContentLinkHandler = {
+ init: function(chromeGlobal) {
+ chromeGlobal.addEventListener("DOMLinkAdded", (event) => {
+ this.onLinkEvent(event, chromeGlobal);
+ }, false);
+ chromeGlobal.addEventListener("DOMLinkChanged", (event) => {
+ this.onLinkEvent(event, chromeGlobal);
+ }, false);
+ },
+
+ onLinkEvent: function(event, chromeGlobal) {
+ var link = event.originalTarget;
+ var rel = link.rel && link.rel.toLowerCase();
+ if (!link || !link.ownerDocument || !rel || !link.href)
+ return;
+
+ // Ignore sub-frames (bugs 305472, 479408).
+ let window = link.ownerGlobal;
+ if (window != window.top)
+ return;
+
+ var feedAdded = false;
+ var iconAdded = false;
+ var searchAdded = false;
+ var rels = {};
+ for (let relString of rel.split(/\s+/))
+ rels[relString] = true;
+
+ for (let relVal in rels) {
+ switch (relVal) {
+ case "feed":
+ case "alternate":
+ if (!feedAdded && event.type == "DOMLinkAdded") {
+ if (!rels.feed && rels.alternate && rels.stylesheet)
+ break;
+
+ if (Feeds.isValidFeed(link, link.ownerDocument.nodePrincipal, "feed" in rels)) {
+ chromeGlobal.sendAsyncMessage("Link:AddFeed",
+ {type: link.type,
+ href: link.href,
+ title: link.title});
+ feedAdded = true;
+ }
+ }
+ break;
+ case "icon":
+ if (iconAdded || !Services.prefs.getBoolPref("browser.chrome.site_icons"))
+ break;
+
+ var uri = this.getLinkIconURI(link);
+ if (!uri)
+ break;
+
+ // Telemetry probes for measuring the sizes attribute
+ // usage and available dimensions.
+ let sizeHistogramTypes = Services.telemetry.
+ getHistogramById("LINK_ICON_SIZES_ATTR_USAGE");
+ let sizeHistogramDimension = Services.telemetry.
+ getHistogramById("LINK_ICON_SIZES_ATTR_DIMENSION");
+ let sizesType;
+ if (link.sizes.length) {
+ for (let size of link.sizes) {
+ if (size.toLowerCase() == "any") {
+ sizesType = SIZES_TELEMETRY_ENUM.ANY;
+ break;
+ } else {
+ let re = /^([1-9][0-9]*)x[1-9][0-9]*$/i;
+ let values = re.exec(size);
+ if (values && values.length > 1) {
+ sizesType = SIZES_TELEMETRY_ENUM.DIMENSION;
+ sizeHistogramDimension.add(parseInt(values[1]));
+ } else {
+ sizesType = SIZES_TELEMETRY_ENUM.INVALID;
+ break;
+ }
+ }
+ }
+ } else {
+ sizesType = SIZES_TELEMETRY_ENUM.NO_SIZES;
+ }
+ sizeHistogramTypes.add(sizesType);
+
+ chromeGlobal.sendAsyncMessage(
+ "Link:SetIcon",
+ {url: uri.spec, loadingPrincipal: link.ownerDocument.nodePrincipal});
+ iconAdded = true;
+ break;
+ case "search":
+ if (!searchAdded && event.type == "DOMLinkAdded") {
+ var type = link.type && link.type.toLowerCase();
+ type = type.replace(/^\s+|\s*(?:;.*)?$/g, "");
+
+ let re = /^(?:https?|ftp):/i;
+ if (type == "application/opensearchdescription+xml" && link.title &&
+ re.test(link.href))
+ {
+ let engine = { title: link.title, href: link.href };
+ chromeGlobal.sendAsyncMessage("Link:AddSearch",
+ {engine: engine,
+ url: link.ownerDocument.documentURI});
+ searchAdded = true;
+ }
+ }
+ break;
+ }
+ }
+ },
+
+ getLinkIconURI: function(aLink) {
+ let targetDoc = aLink.ownerDocument;
+ var uri = BrowserUtils.makeURI(aLink.href, targetDoc.characterSet);
+ try {
+ uri.userPass = "";
+ } catch (e) {
+ // some URIs are immutable
+ }
+ return uri;
+ },
+};
diff --git a/browser/modules/ContentObservers.jsm b/browser/modules/ContentObservers.jsm
new file mode 100644
index 000000000..9d627ddc2
--- /dev/null
+++ b/browser/modules/ContentObservers.jsm
@@ -0,0 +1,55 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This module is for small observers that we want to register once per content
+ * process, usually in order to forward content-based observer service notifications
+ * to the chrome process through message passing. Using a JSM avoids having them
+ * in content.js and thereby registering N observers for N open tabs, which is bad
+ * for perf.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+var gEMEUIObserver = function(subject, topic, data) {
+ let win = subject.top;
+ let mm = getMessageManagerForWindow(win);
+ if (mm) {
+ mm.sendAsyncMessage("EMEVideo:ContentMediaKeysRequest", data);
+ }
+};
+
+var gDecoderDoctorObserver = function(subject, topic, data) {
+ let win = subject.top;
+ let mm = getMessageManagerForWindow(win);
+ if (mm) {
+ mm.sendAsyncMessage("DecoderDoctor:Notification", data);
+ }
+};
+
+function getMessageManagerForWindow(aContentWindow) {
+ let ir = aContentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDocShell)
+ .sameTypeRootTreeItem
+ .QueryInterface(Ci.nsIInterfaceRequestor);
+ try {
+ // If e10s is disabled, this throws NS_NOINTERFACE for closed tabs.
+ return ir.getInterface(Ci.nsIContentFrameMessageManager);
+ } catch (e) {
+ if (e.result == Cr.NS_NOINTERFACE) {
+ return null;
+ }
+ throw e;
+ }
+}
+
+Services.obs.addObserver(gEMEUIObserver, "mediakeys-request", false);
+Services.obs.addObserver(gDecoderDoctorObserver, "decoder-doctor-notification", false);
diff --git a/browser/modules/ContentSearch.jsm b/browser/modules/ContentSearch.jsm
new file mode 100644
index 000000000..91b0b9ac8
--- /dev/null
+++ b/browser/modules/ContentSearch.jsm
@@ -0,0 +1,566 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* globals XPCOMUtils, Services, Task, Promise, SearchSuggestionController, FormHistory, PrivateBrowsingUtils */
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+ "ContentSearch",
+];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
+ "resource://gre/modules/FormHistory.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "SearchSuggestionController",
+ "resource://gre/modules/SearchSuggestionController.jsm");
+
+const INBOUND_MESSAGE = "ContentSearch";
+const OUTBOUND_MESSAGE = INBOUND_MESSAGE;
+const MAX_LOCAL_SUGGESTIONS = 3;
+const MAX_SUGGESTIONS = 6;
+
+/**
+ * ContentSearch receives messages named INBOUND_MESSAGE and sends messages
+ * named OUTBOUND_MESSAGE. The data of each message is expected to look like
+ * { type, data }. type is the message's type (or subtype if you consider the
+ * type of the message itself to be INBOUND_MESSAGE), and data is data that is
+ * specific to the type.
+ *
+ * Inbound messages have the following types:
+ *
+ * AddFormHistoryEntry
+ * Adds an entry to the search form history.
+ * data: the entry, a string
+ * GetSuggestions
+ * Retrieves an array of search suggestions given a search string.
+ * data: { engineName, searchString, [remoteTimeout] }
+ * GetState
+ * Retrieves the current search engine state.
+ * data: null
+ * GetStrings
+ * Retrieves localized search UI strings.
+ * data: null
+ * ManageEngines
+ * Opens the search engine management window.
+ * data: null
+ * RemoveFormHistoryEntry
+ * Removes an entry from the search form history.
+ * data: the entry, a string
+ * Search
+ * Performs a search.
+ * Any GetSuggestions messages in the queue from the same target will be
+ * cancelled.
+ * data: { engineName, searchString, healthReportKey, searchPurpose }
+ * SetCurrentEngine
+ * Sets the current engine.
+ * data: the name of the engine
+ * SpeculativeConnect
+ * Speculatively connects to an engine.
+ * data: the name of the engine
+ *
+ * Outbound messages have the following types:
+ *
+ * CurrentEngine
+ * Broadcast when the current engine changes.
+ * data: see _currentEngineObj
+ * CurrentState
+ * Broadcast when the current search state changes.
+ * data: see currentStateObj
+ * State
+ * Sent in reply to GetState.
+ * data: see currentStateObj
+ * Strings
+ * Sent in reply to GetStrings
+ * data: Object containing string names and values for the current locale.
+ * Suggestions
+ * Sent in reply to GetSuggestions.
+ * data: see _onMessageGetSuggestions
+ * SuggestionsCancelled
+ * Sent in reply to GetSuggestions when pending GetSuggestions events are
+ * cancelled.
+ * data: null
+ */
+
+this.ContentSearch = {
+
+ // Inbound events are queued and processed in FIFO order instead of handling
+ // them immediately, which would result in non-FIFO responses due to the
+ // asynchrononicity added by converting image data URIs to ArrayBuffers.
+ _eventQueue: [],
+ _currentEventPromise: null,
+
+ // This is used to handle search suggestions. It maps xul:browsers to objects
+ // { controller, previousFormHistoryResult }. See _onMessageGetSuggestions.
+ _suggestionMap: new WeakMap(),
+
+ // Resolved when we finish shutting down.
+ _destroyedPromise: null,
+
+ // The current controller and browser in _onMessageGetSuggestions. Allows
+ // fetch cancellation from _cancelSuggestions.
+ _currentSuggestion: null,
+
+ init: function () {
+ Cc["@mozilla.org/globalmessagemanager;1"].
+ getService(Ci.nsIMessageListenerManager).
+ addMessageListener(INBOUND_MESSAGE, this);
+ Services.obs.addObserver(this, "browser-search-engine-modified", false);
+ Services.obs.addObserver(this, "shutdown-leaks-before-check", false);
+ Services.prefs.addObserver("browser.search.hiddenOneOffs", this, false);
+ this._stringBundle = Services.strings.createBundle("chrome://global/locale/autocomplete.properties");
+ },
+
+ get searchSuggestionUIStrings() {
+ if (this._searchSuggestionUIStrings) {
+ return this._searchSuggestionUIStrings;
+ }
+ this._searchSuggestionUIStrings = {};
+ let searchBundle = Services.strings.createBundle("chrome://browser/locale/search.properties");
+ let stringNames = ["searchHeader", "searchPlaceholder", "searchForSomethingWith",
+ "searchWithHeader", "searchSettings"];
+
+ for (let name of stringNames) {
+ this._searchSuggestionUIStrings[name] = searchBundle.GetStringFromName(name);
+ }
+ return this._searchSuggestionUIStrings;
+ },
+
+ destroy: function () {
+ if (this._destroyedPromise) {
+ return this._destroyedPromise;
+ }
+
+ Cc["@mozilla.org/globalmessagemanager;1"].
+ getService(Ci.nsIMessageListenerManager).
+ removeMessageListener(INBOUND_MESSAGE, this);
+ Services.obs.removeObserver(this, "browser-search-engine-modified");
+ Services.obs.removeObserver(this, "shutdown-leaks-before-check");
+
+ this._eventQueue.length = 0;
+ this._destroyedPromise = Promise.resolve(this._currentEventPromise);
+ return this._destroyedPromise;
+ },
+
+ /**
+ * Focuses the search input in the page with the given message manager.
+ * @param messageManager
+ * The MessageManager object of the selected browser.
+ */
+ focusInput: function (messageManager) {
+ messageManager.sendAsyncMessage(OUTBOUND_MESSAGE, {
+ type: "FocusInput"
+ });
+ },
+
+ receiveMessage: function (msg) {
+ // Add a temporary event handler that exists only while the message is in
+ // the event queue. If the message's source docshell changes browsers in
+ // the meantime, then we need to update msg.target. event.detail will be
+ // the docshell's new parent <xul:browser> element.
+ msg.handleEvent = event => {
+ let browserData = this._suggestionMap.get(msg.target);
+ if (browserData) {
+ this._suggestionMap.delete(msg.target);
+ this._suggestionMap.set(event.detail, browserData);
+ }
+ msg.target.removeEventListener("SwapDocShells", msg, true);
+ msg.target = event.detail;
+ msg.target.addEventListener("SwapDocShells", msg, true);
+ };
+ msg.target.addEventListener("SwapDocShells", msg, true);
+
+ // Search requests cause cancellation of all Suggestion requests from the
+ // same browser.
+ if (msg.data.type === "Search") {
+ this._cancelSuggestions(msg);
+ }
+
+ this._eventQueue.push({
+ type: "Message",
+ data: msg,
+ });
+ this._processEventQueue();
+ },
+
+ observe: function (subj, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ case "browser-search-engine-modified":
+ this._eventQueue.push({
+ type: "Observe",
+ data: data,
+ });
+ this._processEventQueue();
+ break;
+ case "shutdown-leaks-before-check":
+ subj.wrappedJSObject.client.addBlocker(
+ "ContentSearch: Wait until the service is destroyed", () => this.destroy());
+ break;
+ }
+ },
+
+ removeFormHistoryEntry: function (msg, entry) {
+ let browserData = this._suggestionDataForBrowser(msg.target);
+ if (browserData && browserData.previousFormHistoryResult) {
+ let { previousFormHistoryResult } = browserData;
+ for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
+ if (previousFormHistoryResult.getValueAt(i) === entry) {
+ previousFormHistoryResult.removeValueAt(i, true);
+ break;
+ }
+ }
+ }
+ },
+
+ performSearch: function (msg, data) {
+ this._ensureDataHasProperties(data, [
+ "engineName",
+ "searchString",
+ "healthReportKey",
+ "searchPurpose",
+ ]);
+ let engine = Services.search.getEngineByName(data.engineName);
+ let submission = engine.getSubmission(data.searchString, "", data.searchPurpose);
+ let browser = msg.target;
+ let win = browser.ownerGlobal;
+ if (!win) {
+ // The browser may have been closed between the time its content sent the
+ // message and the time we handle it.
+ return;
+ }
+ let where = win.whereToOpenLink(data.originalEvent);
+
+ // There is a chance that by the time we receive the search message, the user
+ // has switched away from the tab that triggered the search. If, based on the
+ // event, we need to load the search in the same tab that triggered it (i.e.
+ // where === "current"), openUILinkIn will not work because that tab is no
+ // longer the current one. For this case we manually load the URI.
+ if (where === "current") {
+ browser.loadURIWithFlags(submission.uri.spec,
+ Ci.nsIWebNavigation.LOAD_FLAGS_NONE, null, null,
+ submission.postData);
+ } else {
+ let params = {
+ postData: submission.postData,
+ inBackground: Services.prefs.getBoolPref("browser.tabs.loadInBackground"),
+ };
+ win.openUILinkIn(submission.uri.spec, where, params);
+ }
+ win.BrowserSearch.recordSearchInTelemetry(engine, data.healthReportKey,
+ { selection: data.selection });
+ return;
+ },
+
+ getSuggestions: Task.async(function* (engineName, searchString, browser, remoteTimeout=null) {
+ let engine = Services.search.getEngineByName(engineName);
+ if (!engine) {
+ throw new Error("Unknown engine name: " + engineName);
+ }
+
+ let browserData = this._suggestionDataForBrowser(browser, true);
+ let { controller } = browserData;
+ let ok = SearchSuggestionController.engineOffersSuggestions(engine);
+ controller.maxLocalResults = ok ? MAX_LOCAL_SUGGESTIONS : MAX_SUGGESTIONS;
+ controller.maxRemoteResults = ok ? MAX_SUGGESTIONS : 0;
+ controller.remoteTimeout = remoteTimeout || undefined;
+ let priv = PrivateBrowsingUtils.isBrowserPrivate(browser);
+ // fetch() rejects its promise if there's a pending request, but since we
+ // process our event queue serially, there's never a pending request.
+ this._currentSuggestion = { controller: controller, target: browser };
+ let suggestions = yield controller.fetch(searchString, priv, engine);
+ this._currentSuggestion = null;
+
+ // suggestions will be null if the request was cancelled
+ let result = {};
+ if (!suggestions) {
+ return result;
+ }
+
+ // Keep the form history result so RemoveFormHistoryEntry can remove entries
+ // from it. Keeping only one result isn't foolproof because the client may
+ // try to remove an entry from one set of suggestions after it has requested
+ // more but before it's received them. In that case, the entry may not
+ // appear in the new suggestions. But that should happen rarely.
+ browserData.previousFormHistoryResult = suggestions.formHistoryResult;
+ result = {
+ engineName,
+ term: suggestions.term,
+ local: suggestions.local,
+ remote: suggestions.remote,
+ };
+ return result;
+ }),
+
+ addFormHistoryEntry: Task.async(function* (browser, entry="") {
+ let isPrivate = false;
+ try {
+ // isBrowserPrivate assumes that the passed-in browser has all the normal
+ // properties, which won't be true if the browser has been destroyed.
+ // That may be the case here due to the asynchronous nature of messaging.
+ isPrivate = PrivateBrowsingUtils.isBrowserPrivate(browser.target);
+ } catch (err) {
+ return false;
+ }
+ if (isPrivate || entry === "") {
+ return false;
+ }
+ let browserData = this._suggestionDataForBrowser(browser.target, true);
+ FormHistory.update({
+ op: "bump",
+ fieldname: browserData.controller.formHistoryParam,
+ value: entry,
+ }, {
+ handleCompletion: () => {},
+ handleError: err => {
+ Cu.reportError("Error adding form history entry: " + err);
+ },
+ });
+ return true;
+ }),
+
+ currentStateObj: Task.async(function* (uriFlag=false) {
+ let state = {
+ engines: [],
+ currentEngine: yield this._currentEngineObj(),
+ };
+ if (uriFlag) {
+ state.currentEngine.iconBuffer = Services.search.currentEngine.getIconURLBySize(16, 16);
+ }
+ let pref = Services.prefs.getCharPref("browser.search.hiddenOneOffs");
+ let hiddenList = pref ? pref.split(",") : [];
+ for (let engine of Services.search.getVisibleEngines()) {
+ let uri = engine.getIconURLBySize(16, 16);
+ let iconBuffer = uri;
+ if (!uriFlag) {
+ iconBuffer = yield this._arrayBufferFromDataURI(uri);
+ }
+ state.engines.push({
+ name: engine.name,
+ iconBuffer,
+ hidden: hiddenList.indexOf(engine.name) !== -1,
+ });
+ }
+ return state;
+ }),
+
+ _processEventQueue: function () {
+ if (this._currentEventPromise || !this._eventQueue.length) {
+ return;
+ }
+
+ let event = this._eventQueue.shift();
+
+ this._currentEventPromise = Task.spawn(function* () {
+ try {
+ yield this["_on" + event.type](event.data);
+ } catch (err) {
+ Cu.reportError(err);
+ } finally {
+ this._currentEventPromise = null;
+ this._processEventQueue();
+ }
+ }.bind(this));
+ },
+
+ _cancelSuggestions: function (msg) {
+ let cancelled = false;
+ // cancel active suggestion request
+ if (this._currentSuggestion && this._currentSuggestion.target === msg.target) {
+ this._currentSuggestion.controller.stop();
+ cancelled = true;
+ }
+ // cancel queued suggestion requests
+ for (let i = 0; i < this._eventQueue.length; i++) {
+ let m = this._eventQueue[i].data;
+ if (msg.target === m.target && m.data.type === "GetSuggestions") {
+ this._eventQueue.splice(i, 1);
+ cancelled = true;
+ i--;
+ }
+ }
+ if (cancelled) {
+ this._reply(msg, "SuggestionsCancelled");
+ }
+ },
+
+ _onMessage: Task.async(function* (msg) {
+ let methodName = "_onMessage" + msg.data.type;
+ if (methodName in this) {
+ yield this._initService();
+ yield this[methodName](msg, msg.data.data);
+ if (!Cu.isDeadWrapper(msg.target)) {
+ msg.target.removeEventListener("SwapDocShells", msg, true);
+ }
+ }
+ }),
+
+ _onMessageGetState: function (msg, data) {
+ return this.currentStateObj().then(state => {
+ this._reply(msg, "State", state);
+ });
+ },
+
+ _onMessageGetStrings: function (msg, data) {
+ this._reply(msg, "Strings", this.searchSuggestionUIStrings);
+ },
+
+ _onMessageSearch: function (msg, data) {
+ this.performSearch(msg, data);
+ },
+
+ _onMessageSetCurrentEngine: function (msg, data) {
+ Services.search.currentEngine = Services.search.getEngineByName(data);
+ },
+
+ _onMessageManageEngines: function (msg, data) {
+ let browserWin = msg.target.ownerGlobal;
+ browserWin.openPreferences("paneSearch");
+ },
+
+ _onMessageGetSuggestions: Task.async(function* (msg, data) {
+ this._ensureDataHasProperties(data, [
+ "engineName",
+ "searchString",
+ ]);
+ let {engineName, searchString} = data;
+ let suggestions = yield this.getSuggestions(engineName, searchString, msg.target);
+
+ this._reply(msg, "Suggestions", {
+ engineName: data.engineName,
+ searchString: suggestions.term,
+ formHistory: suggestions.local,
+ remote: suggestions.remote,
+ });
+ }),
+
+ _onMessageAddFormHistoryEntry: Task.async(function* (msg, entry) {
+ yield this.addFormHistoryEntry(msg, entry);
+ }),
+
+ _onMessageRemoveFormHistoryEntry: function (msg, entry) {
+ this.removeFormHistoryEntry(msg, entry);
+ },
+
+ _onMessageSpeculativeConnect: function (msg, engineName) {
+ let engine = Services.search.getEngineByName(engineName);
+ if (!engine) {
+ throw new Error("Unknown engine name: " + engineName);
+ }
+ if (msg.target.contentWindow) {
+ engine.speculativeConnect({
+ window: msg.target.contentWindow,
+ });
+ }
+ },
+
+ _onObserve: Task.async(function* (data) {
+ if (data === "engine-current") {
+ let engine = yield this._currentEngineObj();
+ this._broadcast("CurrentEngine", engine);
+ }
+ else if (data !== "engine-default") {
+ // engine-default is always sent with engine-current and isn't otherwise
+ // relevant to content searches.
+ let state = yield this.currentStateObj();
+ this._broadcast("CurrentState", state);
+ }
+ }),
+
+ _suggestionDataForBrowser: function (browser, create=false) {
+ let data = this._suggestionMap.get(browser);
+ if (!data && create) {
+ // Since one SearchSuggestionController instance is meant to be used per
+ // autocomplete widget, this means that we assume each xul:browser has at
+ // most one such widget.
+ data = {
+ controller: new SearchSuggestionController(),
+ };
+ this._suggestionMap.set(browser, data);
+ }
+ return data;
+ },
+
+ _reply: function (msg, type, data) {
+ // We reply asyncly to messages, and by the time we reply the browser we're
+ // responding to may have been destroyed. messageManager is null then.
+ if (!Cu.isDeadWrapper(msg.target) && msg.target.messageManager) {
+ msg.target.messageManager.sendAsyncMessage(...this._msgArgs(type, data));
+ }
+ },
+
+ _broadcast: function (type, data) {
+ Cc["@mozilla.org/globalmessagemanager;1"].
+ getService(Ci.nsIMessageListenerManager).
+ broadcastAsyncMessage(...this._msgArgs(type, data));
+ },
+
+ _msgArgs: function (type, data) {
+ return [OUTBOUND_MESSAGE, {
+ type: type,
+ data: data,
+ }];
+ },
+
+ _currentEngineObj: Task.async(function* () {
+ let engine = Services.search.currentEngine;
+ let favicon = engine.getIconURLBySize(16, 16);
+ let placeholder = this._stringBundle.formatStringFromName(
+ "searchWithEngine", [engine.name], 1);
+ let obj = {
+ name: engine.name,
+ placeholder: placeholder,
+ iconBuffer: yield this._arrayBufferFromDataURI(favicon),
+ };
+ return obj;
+ }),
+
+ _arrayBufferFromDataURI: function (uri) {
+ if (!uri) {
+ return Promise.resolve(null);
+ }
+ let deferred = Promise.defer();
+ let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
+ createInstance(Ci.nsIXMLHttpRequest);
+ xhr.open("GET", uri, true);
+ xhr.responseType = "arraybuffer";
+ xhr.onload = () => {
+ deferred.resolve(xhr.response);
+ };
+ xhr.onerror = xhr.onabort = xhr.ontimeout = () => {
+ deferred.resolve(null);
+ };
+ try {
+ // This throws if the URI is erroneously encoded.
+ xhr.send();
+ }
+ catch (err) {
+ return Promise.resolve(null);
+ }
+ return deferred.promise;
+ },
+
+ _ensureDataHasProperties: function (data, requiredProperties) {
+ for (let prop of requiredProperties) {
+ if (!(prop in data)) {
+ throw new Error("Message data missing required property: " + prop);
+ }
+ }
+ },
+
+ _initService: function () {
+ if (!this._initServicePromise) {
+ let deferred = Promise.defer();
+ this._initServicePromise = deferred.promise;
+ Services.search.init(() => deferred.resolve());
+ }
+ return this._initServicePromise;
+ },
+};
diff --git a/browser/modules/ContentWebRTC.jsm b/browser/modules/ContentWebRTC.jsm
new file mode 100644
index 000000000..bfb98a868
--- /dev/null
+++ b/browser/modules/ContentWebRTC.jsm
@@ -0,0 +1,392 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
+
+this.EXPORTED_SYMBOLS = [ "ContentWebRTC" ];
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyServiceGetter(this, "MediaManagerService",
+ "@mozilla.org/mediaManagerService;1",
+ "nsIMediaManagerService");
+
+const kBrowserURL = "chrome://browser/content/browser.xul";
+
+this.ContentWebRTC = {
+ _initialized: false,
+
+ init: function() {
+ if (this._initialized)
+ return;
+
+ this._initialized = true;
+ Services.obs.addObserver(handleGUMRequest, "getUserMedia:request", false);
+ Services.obs.addObserver(handlePCRequest, "PeerConnection:request", false);
+ Services.obs.addObserver(updateIndicators, "recording-device-events", false);
+ Services.obs.addObserver(removeBrowserSpecificIndicator, "recording-window-ended", false);
+
+ if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT)
+ Services.obs.addObserver(processShutdown, "content-child-shutdown", false);
+ },
+
+ uninit: function() {
+ Services.obs.removeObserver(handleGUMRequest, "getUserMedia:request");
+ Services.obs.removeObserver(handlePCRequest, "PeerConnection:request");
+ Services.obs.removeObserver(updateIndicators, "recording-device-events");
+ Services.obs.removeObserver(removeBrowserSpecificIndicator, "recording-window-ended");
+
+ if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT)
+ Services.obs.removeObserver(processShutdown, "content-child-shutdown");
+
+ this._initialized = false;
+ },
+
+ // Called only for 'unload' to remove pending gUM prompts in reloaded frames.
+ handleEvent: function(aEvent) {
+ let contentWindow = aEvent.target.defaultView;
+ let mm = getMessageManagerForWindow(contentWindow);
+ for (let key of contentWindow.pendingGetUserMediaRequests.keys()) {
+ mm.sendAsyncMessage("webrtc:CancelRequest", key);
+ }
+ for (let key of contentWindow.pendingPeerConnectionRequests.keys()) {
+ mm.sendAsyncMessage("rtcpeer:CancelRequest", key);
+ }
+ },
+
+ receiveMessage: function(aMessage) {
+ switch (aMessage.name) {
+ case "rtcpeer:Allow":
+ case "rtcpeer:Deny": {
+ let callID = aMessage.data.callID;
+ let contentWindow = Services.wm.getOuterWindowWithId(aMessage.data.windowID);
+ forgetPCRequest(contentWindow, callID);
+ let topic = (aMessage.name == "rtcpeer:Allow") ? "PeerConnection:response:allow" :
+ "PeerConnection:response:deny";
+ Services.obs.notifyObservers(null, topic, callID);
+ break;
+ }
+ case "webrtc:Allow": {
+ let callID = aMessage.data.callID;
+ let contentWindow = Services.wm.getOuterWindowWithId(aMessage.data.windowID);
+ let devices = contentWindow.pendingGetUserMediaRequests.get(callID);
+ forgetGUMRequest(contentWindow, callID);
+
+ let allowedDevices = Cc["@mozilla.org/array;1"]
+ .createInstance(Ci.nsIMutableArray);
+ for (let deviceIndex of aMessage.data.devices)
+ allowedDevices.appendElement(devices[deviceIndex], /* weak =*/ false);
+
+ Services.obs.notifyObservers(allowedDevices, "getUserMedia:response:allow", callID);
+ break;
+ }
+ case "webrtc:Deny":
+ denyGUMRequest(aMessage.data);
+ break;
+ case "webrtc:StopSharing":
+ Services.obs.notifyObservers(null, "getUserMedia:revoke", aMessage.data);
+ break;
+ }
+ }
+};
+
+function handlePCRequest(aSubject, aTopic, aData) {
+ let { windowID, innerWindowID, callID, isSecure } = aSubject;
+ let contentWindow = Services.wm.getOuterWindowWithId(windowID);
+
+ let mm = getMessageManagerForWindow(contentWindow);
+ if (!mm) {
+ // Workaround for Bug 1207784. To use WebRTC, add-ons right now use
+ // hiddenWindow.mozRTCPeerConnection which is only privileged on OSX. Other
+ // platforms end up here without a message manager.
+ // TODO: Remove once there's a better way (1215591).
+
+ // Skip permission check in the absence of a message manager.
+ Services.obs.notifyObservers(null, "PeerConnection:response:allow", callID);
+ return;
+ }
+
+ if (!contentWindow.pendingPeerConnectionRequests) {
+ setupPendingListsInitially(contentWindow);
+ }
+ contentWindow.pendingPeerConnectionRequests.add(callID);
+
+ let request = {
+ windowID: windowID,
+ innerWindowID: innerWindowID,
+ callID: callID,
+ documentURI: contentWindow.document.documentURI,
+ secure: isSecure,
+ };
+ mm.sendAsyncMessage("rtcpeer:Request", request);
+}
+
+function handleGUMRequest(aSubject, aTopic, aData) {
+ let constraints = aSubject.getConstraints();
+ let secure = aSubject.isSecure;
+ let contentWindow = Services.wm.getOuterWindowWithId(aSubject.windowID);
+
+ contentWindow.navigator.mozGetUserMediaDevices(
+ constraints,
+ function (devices) {
+ // If the window has been closed while we were waiting for the list of
+ // devices, there's nothing to do in the callback anymore.
+ if (contentWindow.closed)
+ return;
+
+ prompt(contentWindow, aSubject.windowID, aSubject.callID,
+ constraints, devices, secure);
+ },
+ function (error) {
+ // bug 827146 -- In the future, the UI should catch NotFoundError
+ // and allow the user to plug in a device, instead of immediately failing.
+ denyGUMRequest({callID: aSubject.callID}, error);
+ },
+ aSubject.innerWindowID,
+ aSubject.callID);
+}
+
+function prompt(aContentWindow, aWindowID, aCallID, aConstraints, aDevices, aSecure) {
+ let audioDevices = [];
+ let videoDevices = [];
+ let devices = [];
+
+ // MediaStreamConstraints defines video as 'boolean or MediaTrackConstraints'.
+ let video = aConstraints.video || aConstraints.picture;
+ let audio = aConstraints.audio;
+ let sharingScreen = video && typeof(video) != "boolean" &&
+ video.mediaSource != "camera";
+ let sharingAudio = audio && typeof(audio) != "boolean" &&
+ audio.mediaSource != "microphone";
+ for (let device of aDevices) {
+ device = device.QueryInterface(Ci.nsIMediaDevice);
+ switch (device.type) {
+ case "audio":
+ // Check that if we got a microphone, we have not requested an audio
+ // capture, and if we have requested an audio capture, we are not
+ // getting a microphone instead.
+ if (audio && (device.mediaSource == "microphone") != sharingAudio) {
+ audioDevices.push({name: device.name, deviceIndex: devices.length,
+ id: device.rawId, mediaSource: device.mediaSource});
+ devices.push(device);
+ }
+ break;
+ case "video":
+ // Verify that if we got a camera, we haven't requested a screen share,
+ // or that if we requested a screen share we aren't getting a camera.
+ if (video && (device.mediaSource == "camera") != sharingScreen) {
+ let deviceObject = {name: device.name, deviceIndex: devices.length,
+ id: device.rawId, mediaSource: device.mediaSource};
+ if (device.scary)
+ deviceObject.scary = true;
+ videoDevices.push(deviceObject);
+ devices.push(device);
+ }
+ break;
+ }
+ }
+
+ let requestTypes = [];
+ if (videoDevices.length)
+ requestTypes.push(sharingScreen ? "Screen" : "Camera");
+ if (audioDevices.length)
+ requestTypes.push(sharingAudio ? "AudioCapture" : "Microphone");
+
+ if (!requestTypes.length) {
+ denyGUMRequest({callID: aCallID}, "NotFoundError");
+ return;
+ }
+
+ if (!aContentWindow.pendingGetUserMediaRequests) {
+ setupPendingListsInitially(aContentWindow);
+ }
+ aContentWindow.pendingGetUserMediaRequests.set(aCallID, devices);
+
+ let request = {
+ callID: aCallID,
+ windowID: aWindowID,
+ documentURI: aContentWindow.document.documentURI,
+ secure: aSecure,
+ requestTypes: requestTypes,
+ sharingScreen: sharingScreen,
+ sharingAudio: sharingAudio,
+ audioDevices: audioDevices,
+ videoDevices: videoDevices
+ };
+
+ let mm = getMessageManagerForWindow(aContentWindow);
+ mm.sendAsyncMessage("webrtc:Request", request);
+}
+
+function denyGUMRequest(aData, aError) {
+ let msg = null;
+ if (aError) {
+ msg = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
+ msg.data = aError;
+ }
+ Services.obs.notifyObservers(msg, "getUserMedia:response:deny", aData.callID);
+
+ if (!aData.windowID)
+ return;
+ let contentWindow = Services.wm.getOuterWindowWithId(aData.windowID);
+ if (contentWindow.pendingGetUserMediaRequests)
+ forgetGUMRequest(contentWindow, aData.callID);
+}
+
+function forgetGUMRequest(aContentWindow, aCallID) {
+ aContentWindow.pendingGetUserMediaRequests.delete(aCallID);
+ forgetPendingListsEventually(aContentWindow);
+}
+
+function forgetPCRequest(aContentWindow, aCallID) {
+ aContentWindow.pendingPeerConnectionRequests.delete(aCallID);
+ forgetPendingListsEventually(aContentWindow);
+}
+
+function setupPendingListsInitially(aContentWindow) {
+ if (aContentWindow.pendingGetUserMediaRequests) {
+ return;
+ }
+ aContentWindow.pendingGetUserMediaRequests = new Map();
+ aContentWindow.pendingPeerConnectionRequests = new Set();
+ aContentWindow.addEventListener("unload", ContentWebRTC);
+}
+
+function forgetPendingListsEventually(aContentWindow) {
+ if (aContentWindow.pendingGetUserMediaRequests.size ||
+ aContentWindow.pendingPeerConnectionRequests.size) {
+ return;
+ }
+ aContentWindow.pendingGetUserMediaRequests = null;
+ aContentWindow.pendingPeerConnectionRequests = null;
+ aContentWindow.removeEventListener("unload", ContentWebRTC);
+}
+
+function updateIndicators(aSubject, aTopic, aData) {
+ if (aSubject instanceof Ci.nsIPropertyBag &&
+ aSubject.getProperty("requestURL") == kBrowserURL) {
+ // Ignore notifications caused by the browser UI showing previews.
+ return;
+ }
+
+ let contentWindowArray = MediaManagerService.activeMediaCaptureWindows;
+ let count = contentWindowArray.length;
+
+ let state = {
+ showGlobalIndicator: count > 0,
+ showCameraIndicator: false,
+ showMicrophoneIndicator: false,
+ showScreenSharingIndicator: ""
+ };
+
+ let cpmm = Cc["@mozilla.org/childprocessmessagemanager;1"]
+ .getService(Ci.nsIMessageSender);
+ cpmm.sendAsyncMessage("webrtc:UpdatingIndicators");
+
+ // If several iframes in the same page use media streams, it's possible to
+ // have the same top level window several times. We use a Set to avoid
+ // sending duplicate notifications.
+ let contentWindows = new Set();
+ for (let i = 0; i < count; ++i) {
+ contentWindows.add(contentWindowArray.queryElementAt(i, Ci.nsISupports).top);
+ }
+
+ for (let contentWindow of contentWindows) {
+ if (contentWindow.document.documentURI == kBrowserURL) {
+ // There may be a preview shown at the same time as other streams.
+ continue;
+ }
+
+ let tabState = getTabStateForContentWindow(contentWindow);
+ if (tabState.camera)
+ state.showCameraIndicator = true;
+ if (tabState.microphone)
+ state.showMicrophoneIndicator = true;
+ if (tabState.screen) {
+ if (tabState.screen == "Screen") {
+ state.showScreenSharingIndicator = "Screen";
+ }
+ else if (tabState.screen == "Window") {
+ if (state.showScreenSharingIndicator != "Screen")
+ state.showScreenSharingIndicator = "Window";
+ }
+ else if (tabState.screen == "Application") {
+ if (!state.showScreenSharingIndicator)
+ state.showScreenSharingIndicator = "Application";
+ }
+ else if (tabState.screen == "Browser") {
+ if (!state.showScreenSharingIndicator)
+ state.showScreenSharingIndicator = "Browser";
+ }
+ }
+ let mm = getMessageManagerForWindow(contentWindow);
+ mm.sendAsyncMessage("webrtc:UpdateBrowserIndicators", tabState);
+ }
+
+ cpmm.sendAsyncMessage("webrtc:UpdateGlobalIndicators", state);
+}
+
+function removeBrowserSpecificIndicator(aSubject, aTopic, aData) {
+ let contentWindow = Services.wm.getOuterWindowWithId(aData).top;
+ if (contentWindow.document.documentURI == kBrowserURL) {
+ // Ignore notifications caused by the browser UI showing previews.
+ return;
+ }
+
+ let tabState = getTabStateForContentWindow(contentWindow);
+ if (!tabState.camera && !tabState.microphone && !tabState.screen)
+ tabState = {windowId: tabState.windowId};
+
+ let mm = getMessageManagerForWindow(contentWindow);
+ if (mm)
+ mm.sendAsyncMessage("webrtc:UpdateBrowserIndicators", tabState);
+}
+
+function getTabStateForContentWindow(aContentWindow) {
+ let camera = {}, microphone = {}, screen = {}, window = {}, app = {}, browser = {};
+ MediaManagerService.mediaCaptureWindowState(aContentWindow, camera, microphone,
+ screen, window, app, browser);
+ let tabState = {camera: camera.value, microphone: microphone.value};
+ if (screen.value)
+ tabState.screen = "Screen";
+ else if (window.value)
+ tabState.screen = "Window";
+ else if (app.value)
+ tabState.screen = "Application";
+ else if (browser.value)
+ tabState.screen = "Browser";
+
+ tabState.windowId = getInnerWindowIDForWindow(aContentWindow);
+ tabState.documentURI = aContentWindow.document.documentURI;
+
+ return tabState;
+}
+
+function getInnerWindowIDForWindow(aContentWindow) {
+ return aContentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils)
+ .currentInnerWindowID;
+}
+
+function getMessageManagerForWindow(aContentWindow) {
+ let ir = aContentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDocShell)
+ .sameTypeRootTreeItem
+ .QueryInterface(Ci.nsIInterfaceRequestor);
+ try {
+ // If e10s is disabled, this throws NS_NOINTERFACE for closed tabs.
+ return ir.getInterface(Ci.nsIContentFrameMessageManager);
+ } catch (e) {
+ if (e.result == Cr.NS_NOINTERFACE) {
+ return null;
+ }
+ throw e;
+ }
+}
+
+function processShutdown() {
+ ContentWebRTC.uninit();
+}
diff --git a/browser/modules/DirectoryLinksProvider.jsm b/browser/modules/DirectoryLinksProvider.jsm
new file mode 100644
index 000000000..117564099
--- /dev/null
+++ b/browser/modules/DirectoryLinksProvider.jsm
@@ -0,0 +1,1255 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["DirectoryLinksProvider"];
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+const ParserUtils = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils);
+
+Cu.importGlobalProperties(["XMLHttpRequest"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
+ "resource://gre/modules/NewTabUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+ "resource://gre/modules/osfile.jsm")
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils",
+ "resource://gre/modules/UpdateUtils.jsm");
+XPCOMUtils.defineLazyServiceGetter(this, "eTLD",
+ "@mozilla.org/network/effective-tld-service;1",
+ "nsIEffectiveTLDService");
+XPCOMUtils.defineLazyGetter(this, "gTextDecoder", () => {
+ return new TextDecoder();
+});
+XPCOMUtils.defineLazyGetter(this, "gCryptoHash", function () {
+ return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
+});
+XPCOMUtils.defineLazyGetter(this, "gUnicodeConverter", function () {
+ let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
+ .createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = 'utf8';
+ return converter;
+});
+
+
+// The filename where directory links are stored locally
+const DIRECTORY_LINKS_FILE = "directoryLinks.json";
+const DIRECTORY_LINKS_TYPE = "application/json";
+
+// The preference that tells whether to match the OS locale
+const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS";
+
+// The preference that tells what locale the user selected
+const PREF_SELECTED_LOCALE = "general.useragent.locale";
+
+// The preference that tells where to obtain directory links
+const PREF_DIRECTORY_SOURCE = "browser.newtabpage.directory.source";
+
+// The preference that tells where to send click/view pings
+const PREF_DIRECTORY_PING = "browser.newtabpage.directory.ping";
+
+// The preference that tells if newtab is enhanced
+const PREF_NEWTAB_ENHANCED = "browser.newtabpage.enhanced";
+
+// Only allow link urls that are http(s)
+const ALLOWED_LINK_SCHEMES = new Set(["http", "https"]);
+
+// Only allow link image urls that are https or data
+const ALLOWED_IMAGE_SCHEMES = new Set(["https", "data"]);
+
+// Only allow urls to Mozilla's CDN or empty (for data URIs)
+const ALLOWED_URL_BASE = new Set(["mozilla.net", ""]);
+
+// The frecency of a directory link
+const DIRECTORY_FRECENCY = 1000;
+
+// The frecency of a suggested link
+const SUGGESTED_FRECENCY = Infinity;
+
+// The filename where frequency cap data stored locally
+const FREQUENCY_CAP_FILE = "frequencyCap.json";
+
+// Default settings for daily and total frequency caps
+const DEFAULT_DAILY_FREQUENCY_CAP = 3;
+const DEFAULT_TOTAL_FREQUENCY_CAP = 10;
+
+// Default timeDelta to prune unused frequency cap objects
+// currently set to 10 days in milliseconds
+const DEFAULT_PRUNE_TIME_DELTA = 10*24*60*60*1000;
+
+// The min number of visible (not blocked) history tiles to have before showing suggested tiles
+const MIN_VISIBLE_HISTORY_TILES = 8;
+
+// The max number of visible (not blocked) history tiles to test for inadjacency
+const MAX_VISIBLE_HISTORY_TILES = 15;
+
+// Allowed ping actions remotely stored as columns: case-insensitive [a-z0-9_]
+const PING_ACTIONS = ["block", "click", "pin", "sponsored", "sponsored_link", "unpin", "view"];
+
+// Location of inadjacent sites json
+const INADJACENCY_SOURCE = "chrome://browser/content/newtab/newTab.inadjacent.json";
+
+// Fake URL to keep track of last block of a suggested tile in the frequency cap object
+const FAKE_SUGGESTED_BLOCK_URL = "ignore://suggested_block";
+
+// Time before suggested tile is allowed to play again after block - default to 1 day
+const AFTER_SUGGESTED_BLOCK_DECAY_TIME = 24*60*60*1000;
+
+/**
+ * Singleton that serves as the provider of directory links.
+ * Directory links are a hard-coded set of links shown if a user's link
+ * inventory is empty.
+ */
+var DirectoryLinksProvider = {
+
+ __linksURL: null,
+
+ _observers: new Set(),
+
+ // links download deferred, resolved upon download completion
+ _downloadDeferred: null,
+
+ // download default interval is 24 hours in milliseconds
+ _downloadIntervalMS: 86400000,
+
+ /**
+ * A mapping from eTLD+1 to an enhanced link objects
+ */
+ _enhancedLinks: new Map(),
+
+ /**
+ * A mapping from site to a list of suggested link objects
+ */
+ _suggestedLinks: new Map(),
+
+ /**
+ * Frequency Cap object - maintains daily and total tile counts, and frequency cap settings
+ */
+ _frequencyCaps: {},
+
+ /**
+ * A set of top sites that we can provide suggested links for
+ */
+ _topSitesWithSuggestedLinks: new Set(),
+
+ /**
+ * lookup Set of inadjacent domains
+ */
+ _inadjacentSites: new Set(),
+
+ /**
+ * This flag is set if there is a suggested tile configured to avoid
+ * inadjacent sites in new tab
+ */
+ _avoidInadjacentSites: false,
+
+ /**
+ * This flag is set if _avoidInadjacentSites is true and there is
+ * an inadjacent site in the new tab
+ */
+ _newTabHasInadjacentSite: false,
+
+ get _observedPrefs() {
+ return Object.freeze({
+ enhanced: PREF_NEWTAB_ENHANCED,
+ linksURL: PREF_DIRECTORY_SOURCE,
+ matchOSLocale: PREF_MATCH_OS_LOCALE,
+ prefSelectedLocale: PREF_SELECTED_LOCALE,
+ });
+ },
+
+ get _linksURL() {
+ if (!this.__linksURL) {
+ try {
+ this.__linksURL = Services.prefs.getCharPref(this._observedPrefs["linksURL"]);
+ this.__linksURLModified = Services.prefs.prefHasUserValue(this._observedPrefs["linksURL"]);
+ }
+ catch (e) {
+ Cu.reportError("Error fetching directory links url from prefs: " + e);
+ }
+ }
+ return this.__linksURL;
+ },
+
+ /**
+ * Gets the currently selected locale for display.
+ * @return the selected locale or "en-US" if none is selected
+ */
+ get locale() {
+ let matchOS;
+ try {
+ matchOS = Services.prefs.getBoolPref(PREF_MATCH_OS_LOCALE);
+ }
+ catch (e) {}
+
+ if (matchOS) {
+ return Services.locale.getLocaleComponentForUserAgent();
+ }
+
+ try {
+ let locale = Services.prefs.getComplexValue(PREF_SELECTED_LOCALE,
+ Ci.nsIPrefLocalizedString);
+ if (locale) {
+ return locale.data;
+ }
+ }
+ catch (e) {}
+
+ try {
+ return Services.prefs.getCharPref(PREF_SELECTED_LOCALE);
+ }
+ catch (e) {}
+
+ return "en-US";
+ },
+
+ /**
+ * Set appropriate default ping behavior controlled by enhanced pref
+ */
+ _setDefaultEnhanced: function DirectoryLinksProvider_setDefaultEnhanced() {
+ if (!Services.prefs.prefHasUserValue(PREF_NEWTAB_ENHANCED)) {
+ let enhanced = Services.prefs.getBoolPref(PREF_NEWTAB_ENHANCED);
+ try {
+ // Default to not enhanced if DNT is set to tell websites to not track
+ if (Services.prefs.getBoolPref("privacy.donottrackheader.enabled")) {
+ enhanced = false;
+ }
+ }
+ catch (ex) {}
+ Services.prefs.setBoolPref(PREF_NEWTAB_ENHANCED, enhanced);
+ }
+ },
+
+ observe: function DirectoryLinksProvider_observe(aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed") {
+ switch (aData) {
+ // Re-set the default in case the user clears the pref
+ case this._observedPrefs.enhanced:
+ this._setDefaultEnhanced();
+ break;
+
+ case this._observedPrefs.linksURL:
+ delete this.__linksURL;
+ // fallthrough
+
+ // Force directory download on changes to fetch related prefs
+ case this._observedPrefs.matchOSLocale:
+ case this._observedPrefs.prefSelectedLocale:
+ this._fetchAndCacheLinksIfNecessary(true);
+ break;
+ }
+ }
+ },
+
+ _addPrefsObserver: function DirectoryLinksProvider_addObserver() {
+ for (let pref in this._observedPrefs) {
+ let prefName = this._observedPrefs[pref];
+ Services.prefs.addObserver(prefName, this, false);
+ }
+ },
+
+ _removePrefsObserver: function DirectoryLinksProvider_removeObserver() {
+ for (let pref in this._observedPrefs) {
+ let prefName = this._observedPrefs[pref];
+ Services.prefs.removeObserver(prefName, this);
+ }
+ },
+
+ _cacheSuggestedLinks: function(link) {
+ // Don't cache links that don't have the expected 'frecent_sites'
+ if (!link.frecent_sites) {
+ return;
+ }
+
+ for (let suggestedSite of link.frecent_sites) {
+ let suggestedMap = this._suggestedLinks.get(suggestedSite) || new Map();
+ suggestedMap.set(link.url, link);
+ this._setupStartEndTime(link);
+ this._suggestedLinks.set(suggestedSite, suggestedMap);
+ }
+ },
+
+ _fetchAndCacheLinks: function DirectoryLinksProvider_fetchAndCacheLinks(uri) {
+ // Replace with the same display locale used for selecting links data
+ uri = uri.replace("%LOCALE%", this.locale);
+ uri = uri.replace("%CHANNEL%", UpdateUtils.UpdateChannel);
+
+ return this._downloadJsonData(uri).then(json => {
+ return OS.File.writeAtomic(this._directoryFilePath, json, {tmpPath: this._directoryFilePath + ".tmp"});
+ });
+ },
+
+ /**
+ * Downloads a links with json content
+ * @param download uri
+ * @return promise resolved to json string, "{}" returned if status != 200
+ */
+ _downloadJsonData: function DirectoryLinksProvider__downloadJsonData(uri) {
+ let deferred = Promise.defer();
+ let xmlHttp = this._newXHR();
+
+ xmlHttp.onload = function(aResponse) {
+ let json = this.responseText;
+ if (this.status && this.status != 200) {
+ json = "{}";
+ }
+ deferred.resolve(json);
+ };
+
+ xmlHttp.onerror = function(e) {
+ deferred.reject("Fetching " + uri + " results in error code: " + e.target.status);
+ };
+
+ try {
+ xmlHttp.open("GET", uri);
+ // Override the type so XHR doesn't complain about not well-formed XML
+ xmlHttp.overrideMimeType(DIRECTORY_LINKS_TYPE);
+ // Set the appropriate request type for servers that require correct types
+ xmlHttp.setRequestHeader("Content-Type", DIRECTORY_LINKS_TYPE);
+ xmlHttp.send();
+ } catch (e) {
+ deferred.reject("Error fetching " + uri);
+ Cu.reportError(e);
+ }
+ return deferred.promise;
+ },
+
+ /**
+ * Downloads directory links if needed
+ * @return promise resolved immediately if no download needed, or upon completion
+ */
+ _fetchAndCacheLinksIfNecessary: function DirectoryLinksProvider_fetchAndCacheLinksIfNecessary(forceDownload=false) {
+ if (this._downloadDeferred) {
+ // fetching links already - just return the promise
+ return this._downloadDeferred.promise;
+ }
+
+ if (forceDownload || this._needsDownload) {
+ this._downloadDeferred = Promise.defer();
+ this._fetchAndCacheLinks(this._linksURL).then(() => {
+ // the new file was successfully downloaded and cached, so update a timestamp
+ this._lastDownloadMS = Date.now();
+ this._downloadDeferred.resolve();
+ this._downloadDeferred = null;
+ this._callObservers("onManyLinksChanged")
+ },
+ error => {
+ this._downloadDeferred.resolve();
+ this._downloadDeferred = null;
+ this._callObservers("onDownloadFail");
+ });
+ return this._downloadDeferred.promise;
+ }
+
+ // download is not needed
+ return Promise.resolve();
+ },
+
+ /**
+ * @return true if download is needed, false otherwise
+ */
+ get _needsDownload () {
+ // fail if last download occured less then 24 hours ago
+ if ((Date.now() - this._lastDownloadMS) > this._downloadIntervalMS) {
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Create a new XMLHttpRequest that is anonymous, i.e., doesn't send cookies
+ */
+ _newXHR() {
+ return new XMLHttpRequest({mozAnon: true});
+ },
+
+ /**
+ * Reads directory links file and parses its content
+ * @return a promise resolved to an object with keys 'directory' and 'suggested',
+ * each containing a valid list of links,
+ * or {'directory': [], 'suggested': []} if read or parse fails.
+ */
+ _readDirectoryLinksFile: function DirectoryLinksProvider_readDirectoryLinksFile() {
+ let emptyOutput = {directory: [], suggested: [], enhanced: []};
+ return OS.File.read(this._directoryFilePath).then(binaryData => {
+ let output;
+ try {
+ let json = gTextDecoder.decode(binaryData);
+ let linksObj = JSON.parse(json);
+ output = {directory: linksObj.directory || [],
+ suggested: linksObj.suggested || [],
+ enhanced: linksObj.enhanced || []};
+ }
+ catch (e) {
+ Cu.reportError(e);
+ }
+ return output || emptyOutput;
+ },
+ error => {
+ Cu.reportError(error);
+ return emptyOutput;
+ });
+ },
+
+ /**
+ * Translates link.time_limits to UTC miliseconds and sets
+ * link.startTime and link.endTime properties in link object
+ */
+ _setupStartEndTime: function DirectoryLinksProvider_setupStartEndTime(link) {
+ // set start/end limits. Use ISO_8601 format: '2014-01-10T20:20:20.600Z'
+ // (details here http://en.wikipedia.org/wiki/ISO_8601)
+ // Note that if timezone is missing, FX will interpret as local time
+ // meaning that the server can sepecify any time, but if the capmaign
+ // needs to start at same time across multiple timezones, the server
+ // omits timezone indicator
+ if (!link.time_limits) {
+ return;
+ }
+
+ let parsedTime;
+ if (link.time_limits.start) {
+ parsedTime = Date.parse(link.time_limits.start);
+ if (parsedTime && !isNaN(parsedTime)) {
+ link.startTime = parsedTime;
+ }
+ }
+ if (link.time_limits.end) {
+ parsedTime = Date.parse(link.time_limits.end);
+ if (parsedTime && !isNaN(parsedTime)) {
+ link.endTime = parsedTime;
+ }
+ }
+ },
+
+ /*
+ * Handles campaign timeout
+ */
+ _onCampaignTimeout: function DirectoryLinksProvider_onCampaignTimeout() {
+ // _campaignTimeoutID is invalid here, so just set it to null
+ this._campaignTimeoutID = null;
+ this._updateSuggestedTile();
+ },
+
+ /*
+ * Clears capmpaign timeout
+ */
+ _clearCampaignTimeout: function DirectoryLinksProvider_clearCampaignTimeout() {
+ if (this._campaignTimeoutID) {
+ clearTimeout(this._campaignTimeoutID);
+ this._campaignTimeoutID = null;
+ }
+ },
+
+ /**
+ * Setup capmpaign timeout to recompute suggested tiles upon
+ * reaching soonest start or end time for the campaign
+ * @param timeout in milliseconds
+ */
+ _setupCampaignTimeCheck: function DirectoryLinksProvider_setupCampaignTimeCheck(timeout) {
+ // sanity check
+ if (!timeout || timeout <= 0) {
+ return;
+ }
+ this._clearCampaignTimeout();
+ // setup next timeout
+ this._campaignTimeoutID = setTimeout(this._onCampaignTimeout.bind(this), timeout);
+ },
+
+ /**
+ * Test link for campaign time limits: checks if link falls within start/end time
+ * and returns an object containing a use flag and the timeoutDate milliseconds
+ * when the link has to be re-checked for campaign start-ready or end-reach
+ * @param link
+ * @return object {use: true or false, timeoutDate: milliseconds or null}
+ */
+ _testLinkForCampaignTimeLimits: function DirectoryLinksProvider_testLinkForCampaignTimeLimits(link) {
+ let currentTime = Date.now();
+ // test for start time first
+ if (link.startTime && link.startTime > currentTime) {
+ // not yet ready for start
+ return {use: false, timeoutDate: link.startTime};
+ }
+ // otherwise check for end time
+ if (link.endTime) {
+ // passed end time
+ if (link.endTime <= currentTime) {
+ return {use: false};
+ }
+ // otherwise link is still ok, but we need to set timeoutDate
+ return {use: true, timeoutDate: link.endTime};
+ }
+ // if we are here, the link is ok and no timeoutDate needed
+ return {use: true};
+ },
+
+ /**
+ * Handles block on suggested tile: updates fake block url with current timestamp
+ */
+ handleSuggestedTileBlock: function DirectoryLinksProvider_handleSuggestedTileBlock() {
+ this._updateFrequencyCapSettings({url: FAKE_SUGGESTED_BLOCK_URL});
+ this._writeFrequencyCapFile();
+ this._updateSuggestedTile();
+ },
+
+ /**
+ * Checks if suggested tile is being blocked for the rest of "decay time"
+ * @return True if blocked, false otherwise
+ */
+ _isSuggestedTileBlocked: function DirectoryLinksProvider__isSuggestedTileBlocked() {
+ let capObject = this._frequencyCaps[FAKE_SUGGESTED_BLOCK_URL];
+ if (!capObject || !capObject.lastUpdated) {
+ // user never blocked suggested tile or lastUpdated is missing
+ return false;
+ }
+ // otherwise, make sure that enough time passed after suggested tile was blocked
+ return (capObject.lastUpdated + AFTER_SUGGESTED_BLOCK_DECAY_TIME) > Date.now();
+ },
+
+ /**
+ * Report some action on a newtab page (view, click)
+ * @param sites Array of sites shown on newtab page
+ * @param action String of the behavior to report
+ * @param triggeringSiteIndex optional Int index of the site triggering action
+ * @return download promise
+ */
+ reportSitesAction: function DirectoryLinksProvider_reportSitesAction(sites, action, triggeringSiteIndex) {
+ // Check if the suggested tile was shown
+ if (action == "view") {
+ sites.slice(0, triggeringSiteIndex + 1).filter(s => s).forEach(site => {
+ let {targetedSite, url} = site.link;
+ if (targetedSite) {
+ this._addFrequencyCapView(url);
+ }
+ });
+ }
+ // any click action on a suggested tile should stop that tile suggestion
+ // click/block - user either removed a tile or went to a landing page
+ // pin - tile turned into history tile, should no longer be suggested
+ // unpin - the tile was pinned before, should not matter
+ else {
+ // suggested tile has targetedSite, or frecent_sites if it was pinned
+ let {frecent_sites, targetedSite, url} = sites[triggeringSiteIndex].link;
+ if (frecent_sites || targetedSite) {
+ this._setFrequencyCapClick(url);
+ }
+ }
+
+ let newtabEnhanced = false;
+ let pingEndPoint = "";
+ try {
+ newtabEnhanced = Services.prefs.getBoolPref(PREF_NEWTAB_ENHANCED);
+ pingEndPoint = Services.prefs.getCharPref(PREF_DIRECTORY_PING);
+ }
+ catch (ex) {}
+
+ // Bug 1240245 - We no longer send pings, but frequency capping and fetching
+ // tests depend on the following actions, so references to PING remain.
+ let invalidAction = PING_ACTIONS.indexOf(action) == -1;
+ if (!newtabEnhanced || pingEndPoint == "" || invalidAction) {
+ return Promise.resolve();
+ }
+
+ return Task.spawn(function* () {
+ // since we updated views/clicks we need write _frequencyCaps to disk
+ yield this._writeFrequencyCapFile();
+ // Use this as an opportunity to potentially fetch new links
+ yield this._fetchAndCacheLinksIfNecessary();
+ }.bind(this));
+ },
+
+ /**
+ * Get the enhanced link object for a link (whether history or directory)
+ */
+ getEnhancedLink: function DirectoryLinksProvider_getEnhancedLink(link) {
+ // Use the provided link if it's already enhanced
+ return link.enhancedImageURI && link ? link :
+ this._enhancedLinks.get(NewTabUtils.extractSite(link.url));
+ },
+
+ /**
+ * Check if a url's scheme is in a Set of allowed schemes and if the base
+ * domain is allowed.
+ * @param url to check
+ * @param allowed Set of allowed schemes
+ * @param checkBase boolean to check the base domain
+ */
+ isURLAllowed(url, allowed, checkBase) {
+ // Assume no url is an allowed url
+ if (!url) {
+ return true;
+ }
+
+ let scheme = "", base = "";
+ try {
+ // A malformed url will not be allowed
+ let uri = Services.io.newURI(url, null, null);
+ scheme = uri.scheme;
+
+ // URIs without base domains will be allowed
+ base = Services.eTLD.getBaseDomain(uri);
+ }
+ catch (ex) {}
+ // Require a scheme match and the base only if desired
+ return allowed.has(scheme) && (!checkBase || ALLOWED_URL_BASE.has(base));
+ },
+
+ _escapeChars(text) {
+ let charMap = {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;',
+ '"': '&quot;',
+ "'": '&#039;'
+ };
+
+ return text.replace(/[&<>"']/g, (character) => charMap[character]);
+ },
+
+ /**
+ * Gets the current set of directory links.
+ * @param aCallback The function that the array of links is passed to.
+ */
+ getLinks: function DirectoryLinksProvider_getLinks(aCallback) {
+ this._readDirectoryLinksFile().then(rawLinks => {
+ // Reset the cache of suggested tiles and enhanced images for this new set of links
+ this._enhancedLinks.clear();
+ this._suggestedLinks.clear();
+ this._clearCampaignTimeout();
+ this._avoidInadjacentSites = false;
+
+ // Only check base domain for images when using the default pref
+ let checkBase = !this.__linksURLModified;
+ let validityFilter = function(link) {
+ // Make sure the link url is allowed and images too if they exist
+ return this.isURLAllowed(link.url, ALLOWED_LINK_SCHEMES, false) &&
+ (!link.imageURI ||
+ this.isURLAllowed(link.imageURI, ALLOWED_IMAGE_SCHEMES, checkBase)) &&
+ (!link.enhancedImageURI ||
+ this.isURLAllowed(link.enhancedImageURI, ALLOWED_IMAGE_SCHEMES, checkBase));
+ }.bind(this);
+
+ rawLinks.suggested.filter(validityFilter).forEach((link, position) => {
+ // Suggested sites must have an adgroup name.
+ if (!link.adgroup_name) {
+ return;
+ }
+
+ let sanitizeFlags = ParserUtils.SanitizerCidEmbedsOnly |
+ ParserUtils.SanitizerDropForms |
+ ParserUtils.SanitizerDropNonCSSPresentation;
+
+ link.explanation = this._escapeChars(link.explanation ? ParserUtils.convertToPlainText(link.explanation, sanitizeFlags, 0) : "");
+ link.targetedName = this._escapeChars(ParserUtils.convertToPlainText(link.adgroup_name, sanitizeFlags, 0));
+ link.lastVisitDate = rawLinks.suggested.length - position;
+ // check if link wants to avoid inadjacent sites
+ if (link.check_inadjacency) {
+ this._avoidInadjacentSites = true;
+ }
+
+ // We cache suggested tiles here but do not push any of them in the links list yet.
+ // The decision for which suggested tile to include will be made separately.
+ this._cacheSuggestedLinks(link);
+ this._updateFrequencyCapSettings(link);
+ });
+
+ rawLinks.enhanced.filter(validityFilter).forEach((link, position) => {
+ link.lastVisitDate = rawLinks.enhanced.length - position;
+
+ // Stash the enhanced image for the site
+ if (link.enhancedImageURI) {
+ this._enhancedLinks.set(NewTabUtils.extractSite(link.url), link);
+ }
+ });
+
+ let links = rawLinks.directory.filter(validityFilter).map((link, position) => {
+ link.lastVisitDate = rawLinks.directory.length - position;
+ link.frecency = DIRECTORY_FRECENCY;
+ return link;
+ });
+
+ // Allow for one link suggestion on top of the default directory links
+ this.maxNumLinks = links.length + 1;
+
+ // prune frequency caps of outdated urls
+ this._pruneFrequencyCapUrls();
+ // write frequency caps object to disk asynchronously
+ this._writeFrequencyCapFile();
+
+ return links;
+ }).catch(ex => {
+ Cu.reportError(ex);
+ return [];
+ }).then(links => {
+ aCallback(links);
+ this._populatePlacesLinks();
+ });
+ },
+
+ init: function DirectoryLinksProvider_init() {
+ this._setDefaultEnhanced();
+ this._addPrefsObserver();
+ // setup directory file path and last download timestamp
+ this._directoryFilePath = OS.Path.join(OS.Constants.Path.localProfileDir, DIRECTORY_LINKS_FILE);
+ this._lastDownloadMS = 0;
+
+ // setup frequency cap file path
+ this._frequencyCapFilePath = OS.Path.join(OS.Constants.Path.localProfileDir, FREQUENCY_CAP_FILE);
+ // setup inadjacent sites URL
+ this._inadjacentSitesUrl = INADJACENCY_SOURCE;
+
+ NewTabUtils.placesProvider.addObserver(this);
+ NewTabUtils.links.addObserver(this);
+
+ return Task.spawn(function*() {
+ // get the last modified time of the links file if it exists
+ let doesFileExists = yield OS.File.exists(this._directoryFilePath);
+ if (doesFileExists) {
+ let fileInfo = yield OS.File.stat(this._directoryFilePath);
+ this._lastDownloadMS = Date.parse(fileInfo.lastModificationDate);
+ }
+ // read frequency cap file
+ yield this._readFrequencyCapFile();
+ // fetch directory on startup without force
+ yield this._fetchAndCacheLinksIfNecessary();
+ // fecth inadjacent sites on startup
+ yield this._loadInadjacentSites();
+ }.bind(this));
+ },
+
+ _handleManyLinksChanged: function() {
+ this._topSitesWithSuggestedLinks.clear();
+ this._suggestedLinks.forEach((suggestedLinks, site) => {
+ if (NewTabUtils.isTopPlacesSite(site)) {
+ this._topSitesWithSuggestedLinks.add(site);
+ }
+ });
+ this._updateSuggestedTile();
+ },
+
+ /**
+ * Updates _topSitesWithSuggestedLinks based on the link that was changed.
+ *
+ * @return true if _topSitesWithSuggestedLinks was modified, false otherwise.
+ */
+ _handleLinkChanged: function(aLink) {
+ let changedLinkSite = NewTabUtils.extractSite(aLink.url);
+ let linkStored = this._topSitesWithSuggestedLinks.has(changedLinkSite);
+
+ if (!NewTabUtils.isTopPlacesSite(changedLinkSite) && linkStored) {
+ this._topSitesWithSuggestedLinks.delete(changedLinkSite);
+ return true;
+ }
+
+ if (this._suggestedLinks.has(changedLinkSite) &&
+ NewTabUtils.isTopPlacesSite(changedLinkSite) && !linkStored) {
+ this._topSitesWithSuggestedLinks.add(changedLinkSite);
+ return true;
+ }
+
+ // always run _updateSuggestedTile if aLink is inadjacent
+ // and there are tiles configured to avoid it
+ if (this._avoidInadjacentSites && this._isInadjacentLink(aLink)) {
+ return true;
+ }
+
+ return false;
+ },
+
+ _populatePlacesLinks: function () {
+ NewTabUtils.links.populateProviderCache(NewTabUtils.placesProvider, () => {
+ this._handleManyLinksChanged();
+ });
+ },
+
+ onDeleteURI: function(aProvider, aLink) {
+ let {url} = aLink;
+ // remove clicked flag for that url and
+ // call observer upon disk write completion
+ this._removeTileClick(url).then(() => {
+ this._callObservers("onDeleteURI", url);
+ });
+ },
+
+ onClearHistory: function() {
+ // remove all clicked flags and call observers upon file write
+ this._removeAllTileClicks().then(() => {
+ this._callObservers("onClearHistory");
+ });
+ },
+
+ onLinkChanged: function (aProvider, aLink) {
+ // Make sure NewTabUtils.links handles the notification first.
+ setTimeout(() => {
+ if (this._handleLinkChanged(aLink) || this._shouldUpdateSuggestedTile()) {
+ this._updateSuggestedTile();
+ }
+ }, 0);
+ },
+
+ onManyLinksChanged: function () {
+ // Make sure NewTabUtils.links handles the notification first.
+ setTimeout(() => {
+ this._handleManyLinksChanged();
+ }, 0);
+ },
+
+ _getCurrentTopSiteCount: function() {
+ let visibleTopSiteCount = 0;
+ let newTabLinks = NewTabUtils.links.getLinks();
+ for (let link of newTabLinks.slice(0, MIN_VISIBLE_HISTORY_TILES)) {
+ // compute visibleTopSiteCount for suggested tiles
+ if (link && (link.type == "history" || link.type == "enhanced")) {
+ visibleTopSiteCount++;
+ }
+ }
+ // since newTabLinks are available, set _newTabHasInadjacentSite here
+ // note that _shouldUpdateSuggestedTile is called by _updateSuggestedTile
+ this._newTabHasInadjacentSite = this._avoidInadjacentSites && this._checkForInadjacentSites(newTabLinks);
+
+ return visibleTopSiteCount;
+ },
+
+ _shouldUpdateSuggestedTile: function() {
+ let sortedLinks = NewTabUtils.getProviderLinks(this);
+
+ let mostFrecentLink = {};
+ if (sortedLinks && sortedLinks.length) {
+ mostFrecentLink = sortedLinks[0]
+ }
+
+ let currTopSiteCount = this._getCurrentTopSiteCount();
+ if ((!mostFrecentLink.targetedSite && currTopSiteCount >= MIN_VISIBLE_HISTORY_TILES) ||
+ (mostFrecentLink.targetedSite && currTopSiteCount < MIN_VISIBLE_HISTORY_TILES)) {
+ // If mostFrecentLink has a targetedSite then mostFrecentLink is a suggested link.
+ // If we have enough history links (8+) to show a suggested tile and we are not
+ // already showing one, then we should update (to *attempt* to add a suggested tile).
+ // OR if we don't have enough history to show a suggested tile (<8) and we are
+ // currently showing one, we should update (to remove it).
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Chooses and returns a suggested tile based on a user's top sites
+ * that we have an available suggested tile for.
+ *
+ * @return the chosen suggested tile, or undefined if there isn't one
+ */
+ _updateSuggestedTile: function() {
+ let sortedLinks = NewTabUtils.getProviderLinks(this);
+
+ if (!sortedLinks) {
+ // If NewTabUtils.links.resetCache() is called before getting here,
+ // sortedLinks may be undefined.
+ return undefined;
+ }
+
+ // Delete the current suggested tile, if one exists.
+ let initialLength = sortedLinks.length;
+ if (initialLength) {
+ let mostFrecentLink = sortedLinks[0];
+ if (mostFrecentLink.targetedSite) {
+ this._callObservers("onLinkChanged", {
+ url: mostFrecentLink.url,
+ frecency: SUGGESTED_FRECENCY,
+ lastVisitDate: mostFrecentLink.lastVisitDate,
+ type: mostFrecentLink.type,
+ }, 0, true);
+ }
+ }
+
+ if (this._topSitesWithSuggestedLinks.size == 0 ||
+ !this._shouldUpdateSuggestedTile() ||
+ this._isSuggestedTileBlocked()) {
+ // There are no potential suggested links we can show or not
+ // enough history for a suggested tile, or suggested tile was
+ // recently blocked and wait time interval has not decayed yet
+ return undefined;
+ }
+
+ // Create a flat list of all possible links we can show as suggested.
+ // Note that many top sites may map to the same suggested links, but we only
+ // want to count each suggested link once (based on url), thus possibleLinks is a map
+ // from url to suggestedLink. Thus, each link has an equal chance of being chosen at
+ // random from flattenedLinks if it appears only once.
+ let nextTimeout;
+ let possibleLinks = new Map();
+ let targetedSites = new Map();
+ this._topSitesWithSuggestedLinks.forEach(topSiteWithSuggestedLink => {
+ let suggestedLinksMap = this._suggestedLinks.get(topSiteWithSuggestedLink);
+ suggestedLinksMap.forEach((suggestedLink, url) => {
+ // Skip this link if we've shown it too many times already
+ if (!this._testFrequencyCapLimits(url)) {
+ return;
+ }
+
+ // as we iterate suggestedLinks, check for campaign start/end
+ // time limits, and set nextTimeout to the closest timestamp
+ let {use, timeoutDate} = this._testLinkForCampaignTimeLimits(suggestedLink);
+ // update nextTimeout is necessary
+ if (timeoutDate && (!nextTimeout || nextTimeout > timeoutDate)) {
+ nextTimeout = timeoutDate;
+ }
+ // Skip link if it falls outside campaign time limits
+ if (!use) {
+ return;
+ }
+
+ // Skip link if it avoids inadjacent sites and newtab has one
+ if (suggestedLink.check_inadjacency && this._newTabHasInadjacentSite) {
+ return;
+ }
+
+ possibleLinks.set(url, suggestedLink);
+
+ // Keep a map of URL to targeted sites. We later use this to show the user
+ // what site they visited to trigger this suggestion.
+ if (!targetedSites.get(url)) {
+ targetedSites.set(url, []);
+ }
+ targetedSites.get(url).push(topSiteWithSuggestedLink);
+ })
+ });
+
+ // setup timeout check for starting or ending campaigns
+ if (nextTimeout) {
+ this._setupCampaignTimeCheck(nextTimeout - Date.now());
+ }
+
+ // We might have run out of possible links to show
+ let numLinks = possibleLinks.size;
+ if (numLinks == 0) {
+ return undefined;
+ }
+
+ let flattenedLinks = [...possibleLinks.values()];
+
+ // Choose our suggested link at random
+ let suggestedIndex = Math.floor(Math.random() * numLinks);
+ let chosenSuggestedLink = flattenedLinks[suggestedIndex];
+
+ // Add the suggested link to the front with some extra values
+ this._callObservers("onLinkChanged", Object.assign({
+ frecency: SUGGESTED_FRECENCY,
+
+ // Choose the first site a user has visited as the target. In the future,
+ // this should be the site with the highest frecency. However, we currently
+ // store frecency by URL not by site.
+ targetedSite: targetedSites.get(chosenSuggestedLink.url).length ?
+ targetedSites.get(chosenSuggestedLink.url)[0] : null
+ }, chosenSuggestedLink));
+ return chosenSuggestedLink;
+ },
+
+ /**
+ * Loads inadjacent sites
+ * @return a promise resolved when lookup Set for sites is built
+ */
+ _loadInadjacentSites: function DirectoryLinksProvider_loadInadjacentSites() {
+ return this._downloadJsonData(this._inadjacentSitesUrl).then(jsonString => {
+ let jsonObject = {};
+ try {
+ jsonObject = JSON.parse(jsonString);
+ }
+ catch (e) {
+ Cu.reportError(e);
+ }
+
+ this._inadjacentSites = new Set(jsonObject.domains);
+ });
+ },
+
+ /**
+ * Genegrates hash suitable for looking up inadjacent site
+ * @param value to hsh
+ * @return hased value, base64-ed
+ */
+ _generateHash: function DirectoryLinksProvider_generateHash(value) {
+ let byteArr = gUnicodeConverter.convertToByteArray(value);
+ gCryptoHash.init(gCryptoHash.MD5);
+ gCryptoHash.update(byteArr, byteArr.length);
+ return gCryptoHash.finish(true);
+ },
+
+ /**
+ * Checks if link belongs to inadjacent domain
+ * @param link to check
+ * @return true for inadjacent domains, false otherwise
+ */
+ _isInadjacentLink: function DirectoryLinksProvider_isInadjacentLink(link) {
+ let baseDomain = link.baseDomain || NewTabUtils.extractSite(link.url || "");
+ if (!baseDomain) {
+ return false;
+ }
+ // check if hashed domain is inadjacent
+ return this._inadjacentSites.has(this._generateHash(baseDomain));
+ },
+
+ /**
+ * Checks if new tab has inadjacent site
+ * @param new tab links (or nothing, in which case NewTabUtils.links.getLinks() is called
+ * @return true if new tab shows has inadjacent site
+ */
+ _checkForInadjacentSites: function DirectoryLinksProvider_checkForInadjacentSites(newTabLink) {
+ let links = newTabLink || NewTabUtils.links.getLinks();
+ for (let link of links.slice(0, MAX_VISIBLE_HISTORY_TILES)) {
+ // check links against inadjacent list - specifically include ALL link types
+ if (this._isInadjacentLink(link)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Reads json file, parses its content, and returns resulting object
+ * @param json file path
+ * @param json object to return in case file read or parse fails
+ * @return a promise resolved to a valid object or undefined upon error
+ */
+ _readJsonFile: Task.async(function* (filePath, nullObject) {
+ let jsonObj;
+ try {
+ let binaryData = yield OS.File.read(filePath);
+ let json = gTextDecoder.decode(binaryData);
+ jsonObj = JSON.parse(json);
+ }
+ catch (e) {}
+ return jsonObj || nullObject;
+ }),
+
+ /**
+ * Loads frequency cap object from file and parses its content
+ * @return a promise resolved upon load completion
+ * on error or non-exstent file _frequencyCaps is set to empty object
+ */
+ _readFrequencyCapFile: Task.async(function* () {
+ // set _frequencyCaps object to file's content or empty object
+ this._frequencyCaps = yield this._readJsonFile(this._frequencyCapFilePath, {});
+ }),
+
+ /**
+ * Saves frequency cap object to file
+ * @return a promise resolved upon file i/o completion
+ */
+ _writeFrequencyCapFile: function DirectoryLinksProvider_writeFrequencyCapFile() {
+ let json = JSON.stringify(this._frequencyCaps || {});
+ return OS.File.writeAtomic(this._frequencyCapFilePath, json, {tmpPath: this._frequencyCapFilePath + ".tmp"});
+ },
+
+ /**
+ * Clears frequency cap object and writes empty json to file
+ * @return a promise resolved upon file i/o completion
+ */
+ _clearFrequencyCap: function DirectoryLinksProvider_clearFrequencyCap() {
+ this._frequencyCaps = {};
+ return this._writeFrequencyCapFile();
+ },
+
+ /**
+ * updates frequency cap configuration for a link
+ */
+ _updateFrequencyCapSettings: function DirectoryLinksProvider_updateFrequencyCapSettings(link) {
+ let capsObject = this._frequencyCaps[link.url];
+ if (!capsObject) {
+ // create an object with empty counts
+ capsObject = {
+ dailyViews: 0,
+ totalViews: 0,
+ lastShownDate: 0,
+ };
+ this._frequencyCaps[link.url] = capsObject;
+ }
+ // set last updated timestamp
+ capsObject.lastUpdated = Date.now();
+ // check for link configuration
+ if (link.frequency_caps) {
+ capsObject.dailyCap = link.frequency_caps.daily || DEFAULT_DAILY_FREQUENCY_CAP;
+ capsObject.totalCap = link.frequency_caps.total || DEFAULT_TOTAL_FREQUENCY_CAP;
+ }
+ else {
+ // fallback to defaults
+ capsObject.dailyCap = DEFAULT_DAILY_FREQUENCY_CAP;
+ capsObject.totalCap = DEFAULT_TOTAL_FREQUENCY_CAP;
+ }
+ },
+
+ /**
+ * Prunes frequency cap objects for outdated links
+ * @param timeDetla milliseconds
+ * all cap objects with lastUpdated less than (now() - timeDelta)
+ * will be removed. This is done to remove frequency cap objects
+ * for unused tile urls
+ */
+ _pruneFrequencyCapUrls: function DirectoryLinksProvider_pruneFrequencyCapUrls(timeDelta = DEFAULT_PRUNE_TIME_DELTA) {
+ let timeThreshold = Date.now() - timeDelta;
+ Object.keys(this._frequencyCaps).forEach(url => {
+ // remove url if it is not ignorable and wasn't updated for a while
+ if (!url.startsWith("ignore") && this._frequencyCaps[url].lastUpdated <= timeThreshold) {
+ delete this._frequencyCaps[url];
+ }
+ });
+ },
+
+ /**
+ * Checks if supplied timestamp happened today
+ * @param timestamp in milliseconds
+ * @return true if the timestamp was made today, false otherwise
+ */
+ _wasToday: function DirectoryLinksProvider_wasToday(timestamp) {
+ let showOn = new Date(timestamp);
+ let today = new Date();
+ // call timestamps identical if both day and month are same
+ return showOn.getDate() == today.getDate() &&
+ showOn.getMonth() == today.getMonth() &&
+ showOn.getYear() == today.getYear();
+ },
+
+ /**
+ * adds some number of views for a url
+ * @param url String url of the suggested link
+ */
+ _addFrequencyCapView: function DirectoryLinksProvider_addFrequencyCapView(url) {
+ let capObject = this._frequencyCaps[url];
+ // sanity check
+ if (!capObject) {
+ return;
+ }
+
+ // if the day is new: reset the daily counter and lastShownDate
+ if (!this._wasToday(capObject.lastShownDate)) {
+ capObject.dailyViews = 0;
+ // update lastShownDate
+ capObject.lastShownDate = Date.now();
+ }
+
+ // bump both daily and total counters
+ capObject.totalViews++;
+ capObject.dailyViews++;
+
+ // if any of the caps is reached - update suggested tiles
+ if (capObject.totalViews >= capObject.totalCap ||
+ capObject.dailyViews >= capObject.dailyCap) {
+ this._updateSuggestedTile();
+ }
+ },
+
+ /**
+ * Sets clicked flag for link url
+ * @param url String url of the suggested link
+ */
+ _setFrequencyCapClick(url) {
+ let capObject = this._frequencyCaps[url];
+ // sanity check
+ if (!capObject) {
+ return;
+ }
+ capObject.clicked = true;
+ // and update suggested tiles, since current tile became invalid
+ this._updateSuggestedTile();
+ },
+
+ /**
+ * Tests frequency cap limits for link url
+ * @param url String url of the suggested link
+ * @return true if link is viewable, false otherwise
+ */
+ _testFrequencyCapLimits: function DirectoryLinksProvider_testFrequencyCapLimits(url) {
+ let capObject = this._frequencyCaps[url];
+ // sanity check: if url is missing - do not show this tile
+ if (!capObject) {
+ return false;
+ }
+
+ // check for clicked set or total views reached
+ if (capObject.clicked || capObject.totalViews >= capObject.totalCap) {
+ return false;
+ }
+
+ // otherwise check if link is over daily views limit
+ if (this._wasToday(capObject.lastShownDate) &&
+ capObject.dailyViews >= capObject.dailyCap) {
+ return false;
+ }
+
+ // we passed all cap tests: return true
+ return true;
+ },
+
+ /**
+ * Removes clicked flag from frequency cap entry for tile landing url
+ * @param url String url of the suggested link
+ * @return promise resolved upon disk write completion
+ */
+ _removeTileClick: function DirectoryLinksProvider_removeTileClick(url = "") {
+ // remove trailing slash, to accomodate Places sending site urls ending with '/'
+ let noTrailingSlashUrl = url.replace(/\/$/, "");
+ let capObject = this._frequencyCaps[url] || this._frequencyCaps[noTrailingSlashUrl];
+ // return resolved promise if capObject is not found
+ if (!capObject) {
+ return Promise.resolve();
+ }
+ // otherwise remove clicked flag
+ delete capObject.clicked;
+ return this._writeFrequencyCapFile();
+ },
+
+ /**
+ * Removes all clicked flags from frequency cap object
+ * @return promise resolved upon disk write completion
+ */
+ _removeAllTileClicks: function DirectoryLinksProvider_removeAllTileClicks() {
+ Object.keys(this._frequencyCaps).forEach(url => {
+ delete this._frequencyCaps[url].clicked;
+ });
+ return this._writeFrequencyCapFile();
+ },
+
+ /**
+ * Return the object to its pre-init state
+ */
+ reset: function DirectoryLinksProvider_reset() {
+ delete this.__linksURL;
+ this._removePrefsObserver();
+ this._removeObservers();
+ },
+
+ addObserver: function DirectoryLinksProvider_addObserver(aObserver) {
+ this._observers.add(aObserver);
+ },
+
+ removeObserver: function DirectoryLinksProvider_removeObserver(aObserver) {
+ this._observers.delete(aObserver);
+ },
+
+ _callObservers(methodName, ...args) {
+ for (let obs of this._observers) {
+ if (typeof(obs[methodName]) == "function") {
+ try {
+ obs[methodName](this, ...args);
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ }
+ }
+ },
+
+ _removeObservers: function() {
+ this._observers.clear();
+ }
+};
diff --git a/browser/modules/E10SUtils.jsm b/browser/modules/E10SUtils.jsm
new file mode 100644
index 000000000..7ed51ee96
--- /dev/null
+++ b/browser/modules/E10SUtils.jsm
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["E10SUtils"];
+
+const {interfaces: Ci, utils: Cu, classes: Cc} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+function getAboutModule(aURL) {
+ // Needs to match NS_GetAboutModuleName
+ let moduleName = aURL.path.replace(/[#?].*/, "").toLowerCase();
+ let contract = "@mozilla.org/network/protocol/about;1?what=" + moduleName;
+ try {
+ return Cc[contract].getService(Ci.nsIAboutModule);
+ }
+ catch (e) {
+ // Either the about module isn't defined or it is broken. In either case
+ // ignore it.
+ return null;
+ }
+}
+
+this.E10SUtils = {
+ canLoadURIInProcess: function(aURL, aProcess) {
+ // loadURI in browser.xml treats null as about:blank
+ if (!aURL)
+ aURL = "about:blank";
+
+ // Javascript urls can load in any process, they apply to the current document
+ if (aURL.startsWith("javascript:"))
+ return true;
+
+ let processIsRemote = aProcess == Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT;
+
+ let canLoadRemote = true;
+ let mustLoadRemote = true;
+
+ if (aURL.startsWith("about:")) {
+ let url = Services.io.newURI(aURL, null, null);
+ let module = getAboutModule(url);
+ // If the module doesn't exist then an error page will be loading, that
+ // should be ok to load in either process
+ if (module) {
+ let flags = module.getURIFlags(url);
+ canLoadRemote = !!(flags & Ci.nsIAboutModule.URI_CAN_LOAD_IN_CHILD);
+ mustLoadRemote = !!(flags & Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD);
+ }
+ }
+
+ if (aURL.startsWith("chrome:")) {
+ let url;
+ try {
+ // This can fail for invalid Chrome URIs, in which case we will end up
+ // not loading anything anyway.
+ url = Services.io.newURI(aURL, null, null);
+ } catch (ex) {
+ canLoadRemote = true;
+ mustLoadRemote = false;
+ }
+ if (url) {
+ let chromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].
+ getService(Ci.nsIXULChromeRegistry);
+ canLoadRemote = chromeReg.canLoadURLRemotely(url);
+ mustLoadRemote = chromeReg.mustLoadURLRemotely(url);
+ }
+ }
+
+ if (aURL.startsWith("moz-extension:")) {
+ canLoadRemote = false;
+ mustLoadRemote = false;
+ }
+
+ if (aURL.startsWith("view-source:")) {
+ return this.canLoadURIInProcess(aURL.substr("view-source:".length), aProcess);
+ }
+
+ if (mustLoadRemote)
+ return processIsRemote;
+
+ if (!canLoadRemote && processIsRemote)
+ return false;
+
+ return true;
+ },
+
+ shouldLoadURI: function(aDocShell, aURI, aReferrer) {
+ // Inner frames should always load in the current process
+ if (aDocShell.QueryInterface(Ci.nsIDocShellTreeItem).sameTypeParent)
+ return true;
+
+ // If the URI can be loaded in the current process then continue
+ return this.canLoadURIInProcess(aURI.spec, Services.appinfo.processType);
+ },
+
+ redirectLoad: function(aDocShell, aURI, aReferrer, aFreshProcess) {
+ // Retarget the load to the correct process
+ let messageManager = aDocShell.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIContentFrameMessageManager);
+ let sessionHistory = aDocShell.getInterface(Ci.nsIWebNavigation).sessionHistory;
+
+ messageManager.sendAsyncMessage("Browser:LoadURI", {
+ loadOptions: {
+ uri: aURI.spec,
+ flags: Ci.nsIWebNavigation.LOAD_FLAGS_NONE,
+ referrer: aReferrer ? aReferrer.spec : null,
+ reloadInFreshProcess: !!aFreshProcess,
+ },
+ historyIndex: sessionHistory.requestedIndex,
+ });
+ return false;
+ },
+
+ wrapHandlingUserInput: function(aWindow, aIsHandling, aCallback) {
+ var handlingUserInput;
+ try {
+ handlingUserInput = aWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils)
+ .setHandlingUserInput(aIsHandling);
+ aCallback();
+ } finally {
+ handlingUserInput.destruct();
+ }
+ },
+};
diff --git a/browser/modules/Feeds.jsm b/browser/modules/Feeds.jsm
new file mode 100644
index 000000000..179d2b83d
--- /dev/null
+++ b/browser/modules/Feeds.jsm
@@ -0,0 +1,104 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [ "Feeds" ];
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
+ "resource://gre/modules/BrowserUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
+ "resource:///modules/RecentWindow.jsm");
+
+const { interfaces: Ci, classes: Cc } = Components;
+
+this.Feeds = {
+ init() {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
+ mm.addMessageListener("WCCR:registerProtocolHandler", this);
+ mm.addMessageListener("WCCR:registerContentHandler", this);
+
+ Services.ppmm.addMessageListener("WCCR:setAutoHandler", this);
+ Services.ppmm.addMessageListener("FeedConverter:addLiveBookmark", this);
+ },
+
+ receiveMessage(aMessage) {
+ let data = aMessage.data;
+ switch (aMessage.name) {
+ case "WCCR:registerProtocolHandler": {
+ let registrar = Cc["@mozilla.org/embeddor.implemented/web-content-handler-registrar;1"].
+ getService(Ci.nsIWebContentHandlerRegistrar);
+ registrar.registerProtocolHandler(data.protocol, data.uri, data.title,
+ aMessage.target);
+ break;
+ }
+
+ case "WCCR:registerContentHandler": {
+ let registrar = Cc["@mozilla.org/embeddor.implemented/web-content-handler-registrar;1"].
+ getService(Ci.nsIWebContentHandlerRegistrar);
+ registrar.registerContentHandler(data.contentType, data.uri, data.title,
+ aMessage.target);
+ break;
+ }
+
+ case "WCCR:setAutoHandler": {
+ let registrar = Cc["@mozilla.org/embeddor.implemented/web-content-handler-registrar;1"].
+ getService(Ci.nsIWebContentConverterService);
+ registrar.setAutoHandler(data.contentType, data.handler);
+ break;
+ }
+
+ case "FeedConverter:addLiveBookmark": {
+ let topWindow = RecentWindow.getMostRecentBrowserWindow();
+ topWindow.PlacesCommandHook.addLiveBookmark(data.spec, data.title, data.subtitle)
+ .catch(Components.utils.reportError);
+ break;
+ }
+ }
+ },
+
+ /**
+ * isValidFeed: checks whether the given data represents a valid feed.
+ *
+ * @param aLink
+ * An object representing a feed with title, href and type.
+ * @param aPrincipal
+ * The principal of the document, used for security check.
+ * @param aIsFeed
+ * Whether this is already a known feed or not, if true only a security
+ * check will be performed.
+ */
+ isValidFeed: function(aLink, aPrincipal, aIsFeed) {
+ if (!aLink || !aPrincipal)
+ return false;
+
+ var type = aLink.type.toLowerCase().replace(/^\s+|\s*(?:;.*)?$/g, "");
+ if (!aIsFeed) {
+ aIsFeed = (type == "application/rss+xml" ||
+ type == "application/atom+xml");
+ }
+
+ if (aIsFeed) {
+ // re-create the principal as it may be a CPOW.
+ // once this can't be a CPOW anymore, we should just use aPrincipal instead
+ // of creating a new one.
+ let principalURI = BrowserUtils.makeURIFromCPOW(aPrincipal.URI);
+ let principalToCheck =
+ Services.scriptSecurityManager.createCodebasePrincipal(principalURI, aPrincipal.originAttributes);
+ try {
+ BrowserUtils.urlSecurityCheck(aLink.href, principalToCheck,
+ Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL);
+ return type || "application/rss+xml";
+ }
+ catch (ex) {
+ }
+ }
+
+ return null;
+ },
+
+};
diff --git a/browser/modules/FormSubmitObserver.jsm b/browser/modules/FormSubmitObserver.jsm
new file mode 100644
index 000000000..058794a54
--- /dev/null
+++ b/browser/modules/FormSubmitObserver.jsm
@@ -0,0 +1,235 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Handles the validation callback from nsIFormFillController and
+ * the display of the help panel on invalid elements.
+ */
+
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+var HTMLInputElement = Ci.nsIDOMHTMLInputElement;
+var HTMLTextAreaElement = Ci.nsIDOMHTMLTextAreaElement;
+var HTMLSelectElement = Ci.nsIDOMHTMLSelectElement;
+var HTMLButtonElement = Ci.nsIDOMHTMLButtonElement;
+
+this.EXPORTED_SYMBOLS = [ "FormSubmitObserver" ];
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/BrowserUtils.jsm");
+
+function FormSubmitObserver(aWindow, aTabChildGlobal) {
+ this.init(aWindow, aTabChildGlobal);
+}
+
+FormSubmitObserver.prototype =
+{
+ _validationMessage: "",
+ _content: null,
+ _element: null,
+
+ /*
+ * Public apis
+ */
+
+ init: function(aWindow, aTabChildGlobal)
+ {
+ this._content = aWindow;
+ this._tab = aTabChildGlobal;
+ this._mm =
+ this._content.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDocShell)
+ .sameTypeRootTreeItem
+ .QueryInterface(Ci.nsIDocShell)
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIContentFrameMessageManager);
+
+ // nsIFormSubmitObserver callback about invalid forms. See HTMLFormElement
+ // for details.
+ Services.obs.addObserver(this, "invalidformsubmit", false);
+ this._tab.addEventListener("pageshow", this, false);
+ this._tab.addEventListener("unload", this, false);
+ },
+
+ uninit: function()
+ {
+ Services.obs.removeObserver(this, "invalidformsubmit");
+ this._content.removeEventListener("pageshow", this, false);
+ this._content.removeEventListener("unload", this, false);
+ this._mm = null;
+ this._element = null;
+ this._content = null;
+ this._tab = null;
+ },
+
+ /*
+ * Events
+ */
+
+ handleEvent: function (aEvent) {
+ switch (aEvent.type) {
+ case "pageshow":
+ if (this._isRootDocumentEvent(aEvent)) {
+ this._hidePopup();
+ }
+ break;
+ case "unload":
+ this.uninit();
+ break;
+ case "input":
+ this._onInput(aEvent);
+ break;
+ case "blur":
+ this._onBlur(aEvent);
+ break;
+ }
+ },
+
+ /*
+ * nsIFormSubmitObserver
+ */
+
+ notifyInvalidSubmit : function (aFormElement, aInvalidElements)
+ {
+ // We are going to handle invalid form submission attempt by focusing the
+ // first invalid element and show the corresponding validation message in a
+ // panel attached to the element.
+ if (!aInvalidElements.length) {
+ return;
+ }
+
+ // Insure that this is the FormSubmitObserver associated with the
+ // element / window this notification is about.
+ let element = aInvalidElements.queryElementAt(0, Ci.nsISupports);
+ if (this._content != element.ownerGlobal.top.document.defaultView) {
+ return;
+ }
+
+ if (!(element instanceof HTMLInputElement ||
+ element instanceof HTMLTextAreaElement ||
+ element instanceof HTMLSelectElement ||
+ element instanceof HTMLButtonElement)) {
+ return;
+ }
+
+ // Update validation message before showing notification
+ this._validationMessage = element.validationMessage;
+
+ // Don't connect up to the same element more than once.
+ if (this._element == element) {
+ this._showPopup(element);
+ return;
+ }
+ this._element = element;
+
+ element.focus();
+
+ // Watch for input changes which may change the validation message.
+ element.addEventListener("input", this, false);
+
+ // Watch for focus changes so we can disconnect our listeners and
+ // hide the popup.
+ element.addEventListener("blur", this, false);
+
+ this._showPopup(element);
+ },
+
+ /*
+ * Internal
+ */
+
+ /*
+ * Handles input changes on the form element we've associated a popup
+ * with. Updates the validation message or closes the popup if form data
+ * becomes valid.
+ */
+ _onInput: function (aEvent) {
+ let element = aEvent.originalTarget;
+
+ // If the form input is now valid, hide the popup.
+ if (element.validity.valid) {
+ this._hidePopup();
+ return;
+ }
+
+ // If the element is still invalid for a new reason, we should update
+ // the popup error message.
+ if (this._validationMessage != element.validationMessage) {
+ this._validationMessage = element.validationMessage;
+ this._showPopup(element);
+ }
+ },
+
+ /*
+ * Blur event handler in which we disconnect from the form element and
+ * hide the popup.
+ */
+ _onBlur: function (aEvent) {
+ aEvent.originalTarget.removeEventListener("input", this, false);
+ aEvent.originalTarget.removeEventListener("blur", this, false);
+ this._element = null;
+ this._hidePopup();
+ },
+
+ /*
+ * Send the show popup message to chrome with appropriate position
+ * information. Can be called repetitively to update the currently
+ * displayed popup position and text.
+ */
+ _showPopup: function (aElement) {
+ // Collect positional information and show the popup
+ let panelData = {};
+
+ panelData.message = this._validationMessage;
+
+ // Note, this is relative to the browser and needs to be translated
+ // in chrome.
+ panelData.contentRect = BrowserUtils.getElementBoundingRect(aElement);
+
+ // We want to show the popup at the middle of checkbox and radio buttons
+ // and where the content begin for the other elements.
+ let offset = 0;
+
+ if (aElement.tagName == 'INPUT' &&
+ (aElement.type == 'radio' || aElement.type == 'checkbox')) {
+ panelData.position = "bottomcenter topleft";
+ } else {
+ let win = aElement.ownerGlobal;
+ let style = win.getComputedStyle(aElement, null);
+ if (style.direction == 'rtl') {
+ offset = parseInt(style.paddingRight) + parseInt(style.borderRightWidth);
+ } else {
+ offset = parseInt(style.paddingLeft) + parseInt(style.borderLeftWidth);
+ }
+ let zoomFactor = this._getWindowUtils().fullZoom;
+ panelData.offset = Math.round(offset * zoomFactor);
+ panelData.position = "after_start";
+ }
+ this._mm.sendAsyncMessage("FormValidation:ShowPopup", panelData);
+ },
+
+ _hidePopup: function () {
+ this._mm.sendAsyncMessage("FormValidation:HidePopup", {});
+ },
+
+ _getWindowUtils: function () {
+ return this._content.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
+ },
+
+ _isRootDocumentEvent: function (aEvent) {
+ if (this._content == null) {
+ return true;
+ }
+ let target = aEvent.originalTarget;
+ return (target == this._content.document ||
+ (target.ownerDocument && target.ownerDocument == this._content.document));
+ },
+
+ QueryInterface : XPCOMUtils.generateQI([Ci.nsIFormSubmitObserver])
+};
diff --git a/browser/modules/FormValidationHandler.jsm b/browser/modules/FormValidationHandler.jsm
new file mode 100644
index 000000000..e7e7b14c3
--- /dev/null
+++ b/browser/modules/FormValidationHandler.jsm
@@ -0,0 +1,157 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Chrome side handling of form validation popup.
+ */
+
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = [ "FormValidationHandler" ];
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+var FormValidationHandler =
+{
+ _panel: null,
+ _anchor: null,
+
+ /*
+ * Public apis
+ */
+
+ init: function () {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
+ mm.addMessageListener("FormValidation:ShowPopup", this);
+ mm.addMessageListener("FormValidation:HidePopup", this);
+ },
+
+ uninit: function () {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
+ mm.removeMessageListener("FormValidation:ShowPopup", this);
+ mm.removeMessageListener("FormValidation:HidePopup", this);
+ this._panel = null;
+ this._anchor = null;
+ },
+
+ hidePopup: function () {
+ this._hidePopup();
+ },
+
+ /*
+ * Events
+ */
+
+ receiveMessage: function (aMessage) {
+ let window = aMessage.target.ownerGlobal;
+ let json = aMessage.json;
+ let tabBrowser = window.gBrowser;
+ switch (aMessage.name) {
+ case "FormValidation:ShowPopup":
+ // target is the <browser>, make sure we're receiving a message
+ // from the foreground tab.
+ if (tabBrowser && aMessage.target != tabBrowser.selectedBrowser) {
+ return;
+ }
+ this._showPopup(window, json);
+ break;
+ case "FormValidation:HidePopup":
+ this._hidePopup();
+ break;
+ }
+ },
+
+ observe: function (aSubject, aTopic, aData) {
+ this._hidePopup();
+ },
+
+ handleEvent: function (aEvent) {
+ switch (aEvent.type) {
+ case "FullZoomChange":
+ case "TextZoomChange":
+ case "ZoomChangeUsingMouseWheel":
+ case "scroll":
+ this._hidePopup();
+ break;
+ case "popuphiding":
+ this._onPopupHiding(aEvent);
+ break;
+ }
+ },
+
+ /*
+ * Internal
+ */
+
+ _onPopupHiding: function (aEvent) {
+ aEvent.originalTarget.removeEventListener("popuphiding", this, true);
+ let tabBrowser = aEvent.originalTarget.ownerDocument.getElementById("content");
+ tabBrowser.selectedBrowser.removeEventListener("scroll", this, true);
+ tabBrowser.selectedBrowser.removeEventListener("FullZoomChange", this, false);
+ tabBrowser.selectedBrowser.removeEventListener("TextZoomChange", this, false);
+ tabBrowser.selectedBrowser.removeEventListener("ZoomChangeUsingMouseWheel", this, false);
+
+ this._panel.hidden = true;
+ this._panel = null;
+ this._anchor.hidden = true;
+ this._anchor = null;
+ },
+
+ /*
+ * Shows the form validation popup at a specified position or updates the
+ * messaging and position if the popup is already displayed.
+ *
+ * @aWindow - the chrome window
+ * @aPanelData - Object that contains popup information
+ * aPanelData stucture detail:
+ * contentRect - the bounding client rect of the target element. If
+ * content is remote, this is relative to the browser, otherwise its
+ * relative to the window.
+ * position - popup positional string constants.
+ * message - the form element validation message text.
+ */
+ _showPopup: function (aWindow, aPanelData) {
+ let previouslyShown = !!this._panel;
+ this._panel = aWindow.document.getElementById("invalid-form-popup");
+ this._panel.firstChild.textContent = aPanelData.message;
+ this._panel.hidden = false;
+
+ let tabBrowser = aWindow.gBrowser;
+ this._anchor = tabBrowser.popupAnchor;
+ this._anchor.left = aPanelData.contentRect.left;
+ this._anchor.top = aPanelData.contentRect.top;
+ this._anchor.width = aPanelData.contentRect.width;
+ this._anchor.height = aPanelData.contentRect.height;
+ this._anchor.hidden = false;
+
+ // Display the panel if it isn't already visible.
+ if (!previouslyShown) {
+ // Cleanup after the popup is hidden
+ this._panel.addEventListener("popuphiding", this, true);
+
+ // Hide if the user scrolls the page
+ tabBrowser.selectedBrowser.addEventListener("scroll", this, true);
+ tabBrowser.selectedBrowser.addEventListener("FullZoomChange", this, false);
+ tabBrowser.selectedBrowser.addEventListener("TextZoomChange", this, false);
+ tabBrowser.selectedBrowser.addEventListener("ZoomChangeUsingMouseWheel", this, false);
+
+ // Open the popup
+ this._panel.openPopup(this._anchor, aPanelData.position, 0, 0, false);
+ }
+ },
+
+ /*
+ * Hide the popup if currently displayed. Will fire an event to onPopupHiding
+ * above if visible.
+ */
+ _hidePopup: function () {
+ if (this._panel) {
+ this._panel.hidePopup();
+ }
+ }
+};
diff --git a/browser/modules/HiddenFrame.jsm b/browser/modules/HiddenFrame.jsm
new file mode 100644
index 000000000..7676ae189
--- /dev/null
+++ b/browser/modules/HiddenFrame.jsm
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["HiddenFrame"];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/PromiseUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_PAGE = "data:application/vnd.mozilla.xul+xml;charset=utf-8,<window%20id='win'/>";
+
+/**
+ * An hidden frame object. It takes care of creating an IFRAME and attaching it the
+ * |hiddenDOMWindow|.
+ */
+function HiddenFrame() {}
+
+HiddenFrame.prototype = {
+ _frame: null,
+ _deferred: null,
+ _retryTimerId: null,
+
+ get hiddenDOMDocument() {
+ return Services.appShell.hiddenDOMWindow.document;
+ },
+
+ get isReady() {
+ return this.hiddenDOMDocument.readyState === "complete";
+ },
+
+ /**
+ * Gets the |contentWindow| of the hidden frame. Creates the frame if needed.
+ * @returns Promise Returns a promise which is resolved when the hidden frame has finished
+ * loading.
+ */
+ get: function () {
+ if (!this._deferred) {
+ this._deferred = PromiseUtils.defer();
+ this._create();
+ }
+
+ return this._deferred.promise;
+ },
+
+ destroy: function () {
+ clearTimeout(this._retryTimerId);
+
+ if (this._frame) {
+ if (!Cu.isDeadWrapper(this._frame)) {
+ this._frame.removeEventListener("load", this, true);
+ this._frame.remove();
+ }
+
+ this._frame = null;
+ this._deferred = null;
+ }
+ },
+
+ handleEvent: function () {
+ let contentWindow = this._frame.contentWindow;
+ if (contentWindow.location.href === XUL_PAGE) {
+ this._frame.removeEventListener("load", this, true);
+ this._deferred.resolve(contentWindow);
+ } else {
+ contentWindow.location = XUL_PAGE;
+ }
+ },
+
+ _create: function () {
+ if (this.isReady) {
+ let doc = this.hiddenDOMDocument;
+ this._frame = doc.createElementNS(HTML_NS, "iframe");
+ this._frame.addEventListener("load", this, true);
+ doc.documentElement.appendChild(this._frame);
+ } else {
+ // Check again if |hiddenDOMDocument| is ready as soon as possible.
+ this._retryTimerId = setTimeout(this._create.bind(this), 0);
+ }
+ }
+};
diff --git a/browser/modules/LaterRun.jsm b/browser/modules/LaterRun.jsm
new file mode 100644
index 000000000..4c93a904a
--- /dev/null
+++ b/browser/modules/LaterRun.jsm
@@ -0,0 +1,172 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+this.EXPORTED_SYMBOLS = ["LaterRun"];
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "setInterval", "resource://gre/modules/Timer.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "clearInterval", "resource://gre/modules/Timer.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow", "resource://gre/modules/RecentWindow.jsm");
+
+const kEnabledPref = "browser.laterrun.enabled";
+const kPagePrefRoot = "browser.laterrun.pages.";
+// Number of sessions we've been active in
+const kSessionCountPref = "browser.laterrun.bookkeeping.sessionCount";
+// Time the profile was created at:
+const kProfileCreationTime = "browser.laterrun.bookkeeping.profileCreationTime";
+
+// After 50 sessions or 1 month since install, assume we will no longer be
+// interested in showing anything to "new" users
+const kSelfDestructSessionLimit = 50;
+const kSelfDestructHoursLimit = 31 * 24;
+
+class Page {
+ constructor({pref, minimumHoursSinceInstall, minimumSessionCount, requireBoth, url}) {
+ this.pref = pref;
+ this.minimumHoursSinceInstall = minimumHoursSinceInstall || 0;
+ this.minimumSessionCount = minimumSessionCount || 1;
+ this.requireBoth = requireBoth || false;
+ this.url = url;
+ }
+
+ get hasRun() {
+ return Preferences.get(this.pref + "hasRun", false);
+ }
+
+ applies(sessionInfo) {
+ if (this.hasRun) {
+ return false;
+ }
+ if (this.requireBoth) {
+ return sessionInfo.sessionCount >= this.minimumSessionCount &&
+ sessionInfo.hoursSinceInstall >= this.minimumHoursSinceInstall;
+ }
+ return sessionInfo.sessionCount >= this.minimumSessionCount ||
+ sessionInfo.hoursSinceInstall >= this.minimumHoursSinceInstall;
+ }
+}
+
+let LaterRun = {
+ init() {
+ if (!this.enabled) {
+ return;
+ }
+ // If this is the first run, set the time we were installed
+ if (!Preferences.has(kProfileCreationTime)) {
+ // We need to store seconds in order to fit within int prefs.
+ Preferences.set(kProfileCreationTime, Math.floor(Date.now() / 1000));
+ }
+ this.sessionCount++;
+
+ if (this.hoursSinceInstall > kSelfDestructHoursLimit ||
+ this.sessionCount > kSelfDestructSessionLimit) {
+ this.selfDestruct();
+ return;
+ }
+ },
+
+ // The enabled, hoursSinceInstall and sessionCount properties mirror the
+ // preferences system, and are here for convenience.
+ get enabled() {
+ return Preferences.get(kEnabledPref, false);
+ },
+
+ set enabled(val) {
+ let wasEnabled = this.enabled;
+ Preferences.set(kEnabledPref, val);
+ if (val && !wasEnabled) {
+ this.init();
+ }
+ },
+
+ get hoursSinceInstall() {
+ let installStamp = Preferences.get(kProfileCreationTime, Date.now() / 1000);
+ return Math.floor((Date.now() / 1000 - installStamp) / 3600);
+ },
+
+ get sessionCount() {
+ if (this._sessionCount) {
+ return this._sessionCount;
+ }
+ return this._sessionCount = Preferences.get(kSessionCountPref, 0);
+ },
+
+ set sessionCount(val) {
+ this._sessionCount = val;
+ Preferences.set(kSessionCountPref, val);
+ },
+
+ // Because we don't want to keep incrementing this indefinitely for no reason,
+ // we will turn ourselves off after a set amount of time/sessions (see top of
+ // file).
+ selfDestruct() {
+ Preferences.set(kEnabledPref, false);
+ },
+
+ // Create an array of Page objects based on the currently set prefs
+ readPages() {
+ // Enumerate all the pages.
+ let allPrefsForPages = Services.prefs.getChildList(kPagePrefRoot);
+ let pageDataStore = new Map();
+ for (let pref of allPrefsForPages) {
+ let [slug, prop] = pref.substring(kPagePrefRoot.length).split(".");
+ if (!pageDataStore.has(slug)) {
+ pageDataStore.set(slug, {pref: pref.substring(0, pref.length - prop.length)});
+ }
+ let defaultPrefValue = 0;
+ if (prop == "requireBoth" || prop == "hasRun") {
+ defaultPrefValue = false;
+ } else if (prop == "url") {
+ defaultPrefValue = "";
+ }
+ pageDataStore.get(slug)[prop] = Preferences.get(pref, defaultPrefValue);
+ }
+ let rv = [];
+ for (let [, pageData] of pageDataStore) {
+ if (pageData.url) {
+ let uri = null;
+ try {
+ let urlString = Services.urlFormatter.formatURL(pageData.url.trim());
+ uri = Services.io.newURI(urlString, null, null);
+ } catch (ex) {
+ Cu.reportError("Invalid LaterRun page URL " + pageData.url + " ignored.");
+ continue;
+ }
+ if (!uri.schemeIs("https")) {
+ Cu.reportError("Insecure LaterRun page URL " + uri.spec + " ignored.");
+ } else {
+ pageData.url = uri.spec;
+ rv.push(new Page(pageData));
+ }
+ }
+ }
+ return rv;
+ },
+
+ // Return a URL for display as a 'later run' page if its criteria are matched,
+ // or null otherwise.
+ // NB: will only return one page at a time; if multiple pages match, it's up
+ // to the preference service which one gets shown first, and the next one
+ // will be shown next startup instead.
+ getURL() {
+ if (!this.enabled) {
+ return null;
+ }
+ let pages = this.readPages();
+ let page = pages.find(page => page.applies(this));
+ if (page) {
+ Services.prefs.setBoolPref(page.pref + "hasRun", true);
+ return page.url;
+ }
+ return null;
+ },
+};
+
+LaterRun.init();
diff --git a/browser/modules/NetworkPrioritizer.jsm b/browser/modules/NetworkPrioritizer.jsm
new file mode 100644
index 000000000..770a30e09
--- /dev/null
+++ b/browser/modules/NetworkPrioritizer.jsm
@@ -0,0 +1,194 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This module adjusts network priority for tabs in a way that gives 'important'
+ * tabs a higher priority. There are 3 levels of priority. Each is listed below
+ * with the priority adjustment used.
+ *
+ * Highest (-10): Selected tab in the focused window.
+ * Medium (0): Background tabs in the focused window.
+ * Selected tab in background windows.
+ * Lowest (+10): Background tabs in background windows.
+ */
+
+this.EXPORTED_SYMBOLS = ["trackBrowserWindow"];
+
+const Ci = Components.interfaces;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+
+// Lazy getters
+XPCOMUtils.defineLazyServiceGetter(this, "_focusManager",
+ "@mozilla.org/focus-manager;1",
+ "nsIFocusManager");
+
+
+// Constants
+const TAB_EVENTS = ["TabBrowserInserted", "TabSelect", "TabRemotenessChange"];
+const WINDOW_EVENTS = ["activate", "unload"];
+// lower value means higher priority
+const PRIORITY_DELTA = Ci.nsISupportsPriority.PRIORITY_NORMAL - Ci.nsISupportsPriority.PRIORITY_LOW;
+
+
+// Variables
+var _lastFocusedWindow = null;
+var _windows = [];
+// this is used for restoring the priority after TabRemotenessChange
+var _priorityBackup = new WeakMap();
+
+
+// Exported symbol
+this.trackBrowserWindow = function trackBrowserWindow(aWindow) {
+ WindowHelper.addWindow(aWindow);
+}
+
+
+// Global methods
+function _handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "TabBrowserInserted":
+ BrowserHelper.onOpen(aEvent.target.linkedBrowser);
+ break;
+ case "TabSelect":
+ BrowserHelper.onSelect(aEvent.target.linkedBrowser);
+ break;
+ case "activate":
+ WindowHelper.onActivate(aEvent.target);
+ break;
+ case "TabRemotenessChange":
+ BrowserHelper.onRemotenessChange(aEvent.target.linkedBrowser);
+ break;
+ case "unload":
+ WindowHelper.removeWindow(aEvent.currentTarget);
+ break;
+ }
+}
+
+
+// Methods that impact a browser. Put into single object for organization.
+var BrowserHelper = {
+ onOpen: function NP_BH_onOpen(aBrowser) {
+ _priorityBackup.set(aBrowser.permanentKey, Ci.nsISupportsPriority.PRIORITY_NORMAL);
+
+ // If the tab is in the focused window, leave priority as it is
+ if (aBrowser.ownerGlobal != _lastFocusedWindow)
+ this.decreasePriority(aBrowser);
+ },
+
+ onSelect: function NP_BH_onSelect(aBrowser) {
+ let windowEntry = WindowHelper.getEntry(aBrowser.ownerGlobal);
+ if (windowEntry.lastSelectedBrowser)
+ this.decreasePriority(windowEntry.lastSelectedBrowser);
+ this.increasePriority(aBrowser);
+
+ windowEntry.lastSelectedBrowser = aBrowser;
+ },
+
+ onRemotenessChange: function (aBrowser) {
+ aBrowser.setPriority(_priorityBackup.get(aBrowser.permanentKey));
+ },
+
+ increasePriority: function NP_BH_increasePriority(aBrowser) {
+ aBrowser.adjustPriority(PRIORITY_DELTA);
+ _priorityBackup.set(aBrowser.permanentKey,
+ _priorityBackup.get(aBrowser.permanentKey) + PRIORITY_DELTA);
+ },
+
+ decreasePriority: function NP_BH_decreasePriority(aBrowser) {
+ aBrowser.adjustPriority(PRIORITY_DELTA * -1);
+ _priorityBackup.set(aBrowser.permanentKey,
+ _priorityBackup.get(aBrowser.permanentKey) - PRIORITY_DELTA);
+ }
+};
+
+
+// Methods that impact a window. Put into single object for organization.
+var WindowHelper = {
+ addWindow: function NP_WH_addWindow(aWindow) {
+ // Build internal data object
+ _windows.push({ window: aWindow, lastSelectedBrowser: null });
+
+ // Add event listeners
+ TAB_EVENTS.forEach(function(event) {
+ aWindow.gBrowser.tabContainer.addEventListener(event, _handleEvent, false);
+ });
+ WINDOW_EVENTS.forEach(function(event) {
+ aWindow.addEventListener(event, _handleEvent, false);
+ });
+
+ // This gets called AFTER activate event, so if this is the focused window
+ // we want to activate it. Otherwise, deprioritize it.
+ if (aWindow == _focusManager.activeWindow)
+ this.handleFocusedWindow(aWindow);
+ else
+ this.decreasePriority(aWindow);
+
+ // Select the selected tab
+ BrowserHelper.onSelect(aWindow.gBrowser.selectedBrowser);
+ },
+
+ removeWindow: function NP_WH_removeWindow(aWindow) {
+ if (aWindow == _lastFocusedWindow)
+ _lastFocusedWindow = null;
+
+ // Delete this window from our tracking
+ _windows.splice(this.getEntryIndex(aWindow), 1);
+
+ // Remove the event listeners
+ TAB_EVENTS.forEach(function(event) {
+ aWindow.gBrowser.tabContainer.removeEventListener(event, _handleEvent, false);
+ });
+ WINDOW_EVENTS.forEach(function(event) {
+ aWindow.removeEventListener(event, _handleEvent, false);
+ });
+ },
+
+ onActivate: function NP_WH_onActivate(aWindow, aHasFocus) {
+ // If this window was the last focused window, we don't need to do anything
+ if (aWindow == _lastFocusedWindow)
+ return;
+
+ // handleFocusedWindow will deprioritize the current window
+ this.handleFocusedWindow(aWindow);
+
+ // Lastly we should increase priority for this window
+ this.increasePriority(aWindow);
+ },
+
+ handleFocusedWindow: function NP_WH_handleFocusedWindow(aWindow) {
+ // If we have a last focused window, we need to deprioritize it first
+ if (_lastFocusedWindow)
+ this.decreasePriority(_lastFocusedWindow);
+
+ // aWindow is now focused
+ _lastFocusedWindow = aWindow;
+ },
+
+ // Auxiliary methods
+ increasePriority: function NP_WH_increasePriority(aWindow) {
+ aWindow.gBrowser.browsers.forEach(function(aBrowser) {
+ BrowserHelper.increasePriority(aBrowser);
+ });
+ },
+
+ decreasePriority: function NP_WH_decreasePriority(aWindow) {
+ aWindow.gBrowser.browsers.forEach(function(aBrowser) {
+ BrowserHelper.decreasePriority(aBrowser);
+ });
+ },
+
+ getEntry: function NP_WH_getEntry(aWindow) {
+ return _windows[this.getEntryIndex(aWindow)];
+ },
+
+ getEntryIndex: function NP_WH_getEntryAtIndex(aWindow) {
+ // Assumes that every object has a unique window & it's in the array
+ for (let i = 0; i < _windows.length; i++)
+ if (_windows[i].window == aWindow)
+ return i;
+ }
+};
+
diff --git a/browser/modules/PermissionUI.jsm b/browser/modules/PermissionUI.jsm
new file mode 100644
index 000000000..5fa0f9f06
--- /dev/null
+++ b/browser/modules/PermissionUI.jsm
@@ -0,0 +1,595 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+ "PermissionUI",
+];
+
+/**
+ * PermissionUI is responsible for exposing both a prototype
+ * PermissionPrompt that can be used by arbitrary browser
+ * components and add-ons, but also hosts the implementations of
+ * built-in permission prompts.
+ *
+ * If you're developing a feature that requires web content to ask
+ * for special permissions from the user, this module is for you.
+ *
+ * Suppose a system add-on wants to add a new prompt for a new request
+ * for getting more low-level access to the user's sound card, and the
+ * permission request is coming up from content by way of the
+ * nsContentPermissionHelper. The system add-on could then do the following:
+ *
+ * Cu.import("resource://gre/modules/Integration.jsm");
+ * Cu.import("resource:///modules/PermissionUI.jsm");
+ *
+ * const SoundCardIntegration = (base) => ({
+ * __proto__: base,
+ * createPermissionPrompt(type, request) {
+ * if (type != "sound-api") {
+ * return super.createPermissionPrompt(...arguments);
+ * }
+ *
+ * return {
+ * __proto__: PermissionUI.PermissionPromptForRequestPrototype,
+ * get permissionKey() {
+ * return "sound-permission";
+ * }
+ * // etc - see the documentation for PermissionPrompt for
+ * // a better idea of what things one can and should override.
+ * }
+ * },
+ * });
+ *
+ * // Add-on startup:
+ * Integration.contentPermission.register(SoundCardIntegration);
+ * // ...
+ * // Add-on shutdown:
+ * Integration.contentPermission.unregister(SoundCardIntegration);
+ *
+ * Note that PermissionPromptForRequestPrototype must be used as the
+ * prototype, since the prompt is wrapping an nsIContentPermissionRequest,
+ * and going through nsIContentPermissionPrompt.
+ *
+ * It is, however, possible to take advantage of PermissionPrompt without
+ * having to go through nsIContentPermissionPrompt or with a
+ * nsIContentPermissionRequest. The PermissionPromptPrototype can be
+ * imported, subclassed, and have prompt() called directly, without
+ * the caller having called into createPermissionPrompt.
+ */
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() {
+ return Services.strings
+ .createBundle('chrome://branding/locale/brand.properties');
+});
+
+XPCOMUtils.defineLazyGetter(this, "gBrowserBundle", function() {
+ return Services.strings
+ .createBundle('chrome://browser/locale/browser.properties');
+});
+
+this.PermissionUI = {};
+
+/**
+ * PermissionPromptPrototype should be subclassed by callers that
+ * want to display prompts to the user. See each method and property
+ * below for guidance on what to override.
+ *
+ * Note that if you're creating a prompt for an
+ * nsIContentPermissionRequest, you'll want to subclass
+ * PermissionPromptForRequestPrototype instead.
+ */
+this.PermissionPromptPrototype = {
+ /**
+ * Returns the associated <xul:browser> for the request. This should
+ * work for the e10s and non-e10s case.
+ *
+ * Subclasses must override this.
+ *
+ * @return {<xul:browser>}
+ */
+ get browser() {
+ throw new Error("Not implemented.");
+ },
+
+ /**
+ * Returns the nsIPrincipal associated with the request.
+ *
+ * Subclasses must override this.
+ *
+ * @return {nsIPrincipal}
+ */
+ get principal() {
+ throw new Error("Not implemented.");
+ },
+
+ /**
+ * If the nsIPermissionManager is being queried and written
+ * to for this permission request, set this to the key to be
+ * used. If this is undefined, user permissions will not be
+ * read from or written to.
+ *
+ * Note that if a permission is set, in any follow-up
+ * prompting within the expiry window of that permission,
+ * the prompt will be skipped and the allow or deny choice
+ * will be selected automatically.
+ */
+ get permissionKey() {
+ return undefined;
+ },
+
+ /**
+ * These are the options that will be passed to the
+ * PopupNotification when it is shown. See the documentation
+ * for PopupNotification for more details.
+ *
+ * Note that prompt() will automatically set displayURI to
+ * be the URI of the requesting pricipal, unless the displayURI is exactly
+ * set to false.
+ */
+ get popupOptions() {
+ return {};
+ },
+
+ /**
+ * PopupNotification requires a unique ID to open the notification.
+ * You must return a unique ID string here, for which PopupNotification
+ * will then create a <xul:popupnotification> node with the ID
+ * "<notificationID>-notification".
+ *
+ * If there's a custom <xul:popupnotification> you're hoping to show,
+ * then you need to make sure its ID has the "-notification" suffix,
+ * and then return the prefix here.
+ *
+ * See PopupNotification.jsm for more details.
+ *
+ * @return {string}
+ * The unique ID that will be used to as the
+ * "<unique ID>-notification" ID for the <xul:popupnotification>
+ * to use or create.
+ */
+ get notificationID() {
+ throw new Error("Not implemented.");
+ },
+
+ /**
+ * The ID of the element to anchor the PopupNotification to.
+ *
+ * @return {string}
+ */
+ get anchorID() {
+ return "default-notification-icon";
+ },
+
+ /**
+ * The message to show the user in the PopupNotification. This
+ * is usually a string describing the permission that is being
+ * requested.
+ *
+ * Subclasses must override this.
+ *
+ * @return {string}
+ */
+ get message() {
+ throw new Error("Not implemented.");
+ },
+
+ /**
+ * This will be called if the request is to be cancelled.
+ *
+ * Subclasses only need to override this if they provide a
+ * permissionKey.
+ */
+ cancel() {
+ throw new Error("Not implemented.")
+ },
+
+ /**
+ * This will be called if the request is to be allowed.
+ *
+ * Subclasses only need to override this if they provide a
+ * permissionKey.
+ */
+ allow() {
+ throw new Error("Not implemented.");
+ },
+
+ /**
+ * The actions that will be displayed in the PopupNotification
+ * via a dropdown menu. The first item in this array will be
+ * the default selection. Each action is an Object with the
+ * following properties:
+ *
+ * label (string):
+ * The label that will be displayed for this choice.
+ * accessKey (string):
+ * The access key character that will be used for this choice.
+ * action (Ci.nsIPermissionManager action, optional)
+ * The nsIPermissionManager action that will be associated with
+ * this choice. For example, Ci.nsIPermissionManager.DENY_ACTION.
+ *
+ * If omitted, the nsIPermissionManager will not be written to
+ * when this choice is chosen.
+ * expireType (Ci.nsIPermissionManager expiration policy, optional)
+ * The nsIPermissionManager expiration policy that will be associated
+ * with this choice. For example, Ci.nsIPermissionManager.EXPIRE_SESSION.
+ *
+ * If action is not set, expireType will be ignored.
+ * callback (function, optional)
+ * A callback function that will fire if the user makes this choice.
+ */
+ get promptActions() {
+ return [];
+ },
+
+ /**
+ * If the prompt will be shown to the user, this callback will
+ * be called just before. Subclasses may want to override this
+ * in order to, for example, bump a counter Telemetry probe for
+ * how often a particular permission request is seen.
+ */
+ onBeforeShow() {},
+
+ /**
+ * Will determine if a prompt should be shown to the user, and if so,
+ * will show it.
+ *
+ * If a permissionKey is defined prompt() might automatically
+ * allow or cancel itself based on the user's current
+ * permission settings without displaying the prompt.
+ *
+ * If the <xul:browser> that the request is associated with
+ * does not belong to a browser window with the PopupNotifications
+ * global set, the prompt request is ignored.
+ */
+ prompt() {
+ let chromeWin = this.browser.ownerGlobal;
+ if (!chromeWin.PopupNotifications) {
+ return;
+ }
+
+ // We ignore requests from non-nsIStandardURLs
+ let requestingURI = this.principal.URI;
+ if (!(requestingURI instanceof Ci.nsIStandardURL)) {
+ return;
+ }
+
+ if (this.permissionKey) {
+ // If we're reading and setting permissions, then we need
+ // to check to see if we already have a permission setting
+ // for this particular principal.
+ let result =
+ Services.perms.testExactPermissionFromPrincipal(this.principal,
+ this.permissionKey);
+
+ if (result == Ci.nsIPermissionManager.DENY_ACTION) {
+ this.cancel();
+ return;
+ }
+
+ if (result == Ci.nsIPermissionManager.ALLOW_ACTION) {
+ this.allow();
+ return;
+ }
+ }
+
+ // Transform the PermissionPrompt actions into PopupNotification actions.
+ let popupNotificationActions = [];
+ for (let promptAction of this.promptActions) {
+ // Don't offer action in PB mode if the action remembers permission
+ // for more than a session.
+ if (PrivateBrowsingUtils.isWindowPrivate(chromeWin) &&
+ promptAction.expireType != Ci.nsIPermissionManager.EXPIRE_SESSION &&
+ promptAction.action) {
+ continue;
+ }
+
+ let action = {
+ label: promptAction.label,
+ accessKey: promptAction.accessKey,
+ callback: () => {
+ if (promptAction.callback) {
+ promptAction.callback();
+ }
+
+ if (this.permissionKey) {
+ // Remember permissions.
+ if (promptAction.action) {
+ Services.perms.addFromPrincipal(this.principal,
+ this.permissionKey,
+ promptAction.action,
+ promptAction.expireType);
+ }
+
+ // Grant permission if action is null or ALLOW_ACTION.
+ if (!promptAction.action ||
+ promptAction.action == Ci.nsIPermissionManager.ALLOW_ACTION) {
+ this.allow();
+ } else {
+ this.cancel();
+ }
+ }
+ },
+ };
+ if (promptAction.dismiss) {
+ action.dismiss = promptAction.dismiss
+ }
+
+ popupNotificationActions.push(action);
+ }
+
+ let mainAction = popupNotificationActions.length ?
+ popupNotificationActions[0] : null;
+ let secondaryActions = popupNotificationActions.splice(1);
+
+ let options = this.popupOptions;
+
+ if (!options.hasOwnProperty('displayURI') || options.displayURI) {
+ options.displayURI = this.principal.URI;
+ }
+
+ this.onBeforeShow();
+ chromeWin.PopupNotifications.show(this.browser,
+ this.notificationID,
+ this.message,
+ this.anchorID,
+ mainAction,
+ secondaryActions,
+ options);
+ },
+};
+
+PermissionUI.PermissionPromptPrototype = PermissionPromptPrototype;
+
+/**
+ * A subclass of PermissionPromptPrototype that assumes
+ * that this.request is an nsIContentPermissionRequest
+ * and fills in some of the required properties on the
+ * PermissionPrompt. For callers that are wrapping an
+ * nsIContentPermissionRequest, this should be subclassed
+ * rather than PermissionPromptPrototype.
+ */
+this.PermissionPromptForRequestPrototype = {
+ __proto__: PermissionPromptPrototype,
+
+ get browser() {
+ // In the e10s-case, the <xul:browser> will be at request.element.
+ // In the single-process case, we have to use some XPCOM incantations
+ // to resolve to the <xul:browser>.
+ if (this.request.element) {
+ return this.request.element;
+ }
+ return this.request
+ .window
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShell)
+ .chromeEventHandler;
+ },
+
+ get principal() {
+ return this.request.principal;
+ },
+
+ cancel() {
+ this.request.cancel();
+ },
+
+ allow() {
+ this.request.allow();
+ },
+};
+
+PermissionUI.PermissionPromptForRequestPrototype =
+ PermissionPromptForRequestPrototype;
+
+/**
+ * Creates a PermissionPrompt for a nsIContentPermissionRequest for
+ * the GeoLocation API.
+ *
+ * @param request (nsIContentPermissionRequest)
+ * The request for a permission from content.
+ */
+function GeolocationPermissionPrompt(request) {
+ this.request = request;
+}
+
+GeolocationPermissionPrompt.prototype = {
+ __proto__: PermissionPromptForRequestPrototype,
+
+ get permissionKey() {
+ return "geo";
+ },
+
+ get popupOptions() {
+ let pref = "browser.geolocation.warning.infoURL";
+ return {
+ learnMoreURL: Services.urlFormatter.formatURLPref(pref),
+ };
+ },
+
+ get notificationID() {
+ return "geolocation";
+ },
+
+ get anchorID() {
+ return "geo-notification-icon";
+ },
+
+ get message() {
+ let message;
+ if (this.principal.URI.schemeIs("file")) {
+ message = gBrowserBundle.GetStringFromName("geolocation.shareWithFile2");
+ } else {
+ message = gBrowserBundle.GetStringFromName("geolocation.shareWithSite2");
+ }
+ return message;
+ },
+
+ get promptActions() {
+ // We collect Telemetry data on Geolocation prompts and how users
+ // respond to them. The probe keys are a bit verbose, so let's alias them.
+ const SHARE_LOCATION =
+ Ci.nsISecurityUITelemetry.WARNING_GEOLOCATION_REQUEST_SHARE_LOCATION;
+ const ALWAYS_SHARE =
+ Ci.nsISecurityUITelemetry.WARNING_GEOLOCATION_REQUEST_ALWAYS_SHARE;
+ const NEVER_SHARE =
+ Ci.nsISecurityUITelemetry.WARNING_GEOLOCATION_REQUEST_NEVER_SHARE;
+
+ let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
+
+ let actions = [{
+ label: gBrowserBundle.GetStringFromName("geolocation.shareLocation"),
+ accessKey:
+ gBrowserBundle.GetStringFromName("geolocation.shareLocation.accesskey"),
+ action: null,
+ expireType: null,
+ callback: function() {
+ secHistogram.add(SHARE_LOCATION);
+ },
+ }];
+
+ if (!this.principal.URI.schemeIs("file")) {
+ // Always share location action.
+ actions.push({
+ label: gBrowserBundle.GetStringFromName("geolocation.alwaysShareLocation"),
+ accessKey:
+ gBrowserBundle.GetStringFromName("geolocation.alwaysShareLocation.accesskey"),
+ action: Ci.nsIPermissionManager.ALLOW_ACTION,
+ expireType: null,
+ callback: function() {
+ secHistogram.add(ALWAYS_SHARE);
+ },
+ });
+
+ // Never share location action.
+ actions.push({
+ label: gBrowserBundle.GetStringFromName("geolocation.neverShareLocation"),
+ accessKey:
+ gBrowserBundle.GetStringFromName("geolocation.neverShareLocation.accesskey"),
+ action: Ci.nsIPermissionManager.DENY_ACTION,
+ expireType: null,
+ callback: function() {
+ secHistogram.add(NEVER_SHARE);
+ },
+ });
+ }
+
+ return actions;
+ },
+
+ onBeforeShow() {
+ let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
+ const SHOW_REQUEST = Ci.nsISecurityUITelemetry.WARNING_GEOLOCATION_REQUEST;
+ secHistogram.add(SHOW_REQUEST);
+ },
+};
+
+PermissionUI.GeolocationPermissionPrompt = GeolocationPermissionPrompt;
+
+/**
+ * Creates a PermissionPrompt for a nsIContentPermissionRequest for
+ * the Desktop Notification API.
+ *
+ * @param request (nsIContentPermissionRequest)
+ * The request for a permission from content.
+ * @return {PermissionPrompt} (see documentation in header)
+ */
+function DesktopNotificationPermissionPrompt(request) {
+ this.request = request;
+}
+
+DesktopNotificationPermissionPrompt.prototype = {
+ __proto__: PermissionPromptForRequestPrototype,
+
+ get permissionKey() {
+ return "desktop-notification";
+ },
+
+ get popupOptions() {
+ let learnMoreURL =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") + "push";
+
+ // The eventCallback is bound to the Notification that's being
+ // shown. We'll stash a reference to this in the closure so that
+ // the request can be cancelled.
+ let prompt = this;
+
+ let eventCallback = function(type) {
+ if (type == "dismissed") {
+ // Bug 1259148: Hide the doorhanger icon. Unlike other permission
+ // doorhangers, the user can't restore the doorhanger using the icon
+ // in the location bar. Instead, the site will be notified that the
+ // doorhanger was dismissed.
+ this.remove();
+ prompt.request.cancel();
+ }
+ };
+
+ return {
+ learnMoreURL,
+ eventCallback,
+ };
+ },
+
+ get notificationID() {
+ return "web-notifications";
+ },
+
+ get anchorID() {
+ return "web-notifications-notification-icon";
+ },
+
+ get message() {
+ return gBrowserBundle.GetStringFromName("webNotifications.receiveFromSite");
+ },
+
+ get promptActions() {
+ let promptActions;
+ // Only show "allow for session" in PB mode, we don't
+ // support "allow for session" in non-PB mode.
+ if (PrivateBrowsingUtils.isBrowserPrivate(this.browser)) {
+ promptActions = [
+ {
+ label: gBrowserBundle.GetStringFromName("webNotifications.receiveForSession"),
+ accessKey:
+ gBrowserBundle.GetStringFromName("webNotifications.receiveForSession.accesskey"),
+ action: Ci.nsIPermissionManager.ALLOW_ACTION,
+ expireType: Ci.nsIPermissionManager.EXPIRE_SESSION,
+ }
+ ];
+ } else {
+ promptActions = [
+ {
+ label: gBrowserBundle.GetStringFromName("webNotifications.alwaysReceive"),
+ accessKey:
+ gBrowserBundle.GetStringFromName("webNotifications.alwaysReceive.accesskey"),
+ action: Ci.nsIPermissionManager.ALLOW_ACTION,
+ expireType: null,
+ },
+ {
+ label: gBrowserBundle.GetStringFromName("webNotifications.neverShow"),
+ accessKey:
+ gBrowserBundle.GetStringFromName("webNotifications.neverShow.accesskey"),
+ action: Ci.nsIPermissionManager.DENY_ACTION,
+ expireType: null,
+ },
+ ];
+ }
+
+ return promptActions;
+ },
+};
+
+PermissionUI.DesktopNotificationPermissionPrompt =
+ DesktopNotificationPermissionPrompt;
diff --git a/browser/modules/PluginContent.jsm b/browser/modules/PluginContent.jsm
new file mode 100644
index 000000000..1bbfa9a50
--- /dev/null
+++ b/browser/modules/PluginContent.jsm
@@ -0,0 +1,1154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = [ "PluginContent" ];
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/BrowserUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "gNavigatorBundle", function() {
+ const url = "chrome://browser/locale/browser.properties";
+ return Services.strings.createBundle(url);
+});
+
+XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
+ "resource://gre/modules/AppConstants.jsm");
+
+this.PluginContent = function (global) {
+ this.init(global);
+}
+
+const FLASH_MIME_TYPE = "application/x-shockwave-flash";
+const REPLACEMENT_STYLE_SHEET = Services.io.newURI("chrome://pluginproblem/content/pluginReplaceBinding.css", null, null);
+
+PluginContent.prototype = {
+ init: function (global) {
+ this.global = global;
+ // Need to hold onto the content window or else it'll get destroyed
+ this.content = this.global.content;
+ // Cache of plugin actions for the current page.
+ this.pluginData = new Map();
+ // Cache of plugin crash information sent from the parent
+ this.pluginCrashData = new Map();
+
+ // Note that the XBL binding is untrusted
+ global.addEventListener("PluginBindingAttached", this, true, true);
+ global.addEventListener("PluginPlaceholderReplaced", this, true, true);
+ global.addEventListener("PluginCrashed", this, true);
+ global.addEventListener("PluginOutdated", this, true);
+ global.addEventListener("PluginInstantiated", this, true);
+ global.addEventListener("PluginRemoved", this, true);
+ global.addEventListener("pagehide", this, true);
+ global.addEventListener("pageshow", this, true);
+ global.addEventListener("unload", this);
+ global.addEventListener("HiddenPlugin", this, true);
+
+ global.addMessageListener("BrowserPlugins:ActivatePlugins", this);
+ global.addMessageListener("BrowserPlugins:NotificationShown", this);
+ global.addMessageListener("BrowserPlugins:ContextMenuCommand", this);
+ global.addMessageListener("BrowserPlugins:NPAPIPluginProcessCrashed", this);
+ global.addMessageListener("BrowserPlugins:CrashReportSubmitted", this);
+ global.addMessageListener("BrowserPlugins:Test:ClearCrashData", this);
+
+ Services.obs.addObserver(this, "decoder-doctor-notification", false);
+ },
+
+ uninit: function() {
+ let global = this.global;
+
+ global.removeEventListener("PluginBindingAttached", this, true);
+ global.removeEventListener("PluginPlaceholderReplaced", this, true, true);
+ global.removeEventListener("PluginCrashed", this, true);
+ global.removeEventListener("PluginOutdated", this, true);
+ global.removeEventListener("PluginInstantiated", this, true);
+ global.removeEventListener("PluginRemoved", this, true);
+ global.removeEventListener("pagehide", this, true);
+ global.removeEventListener("pageshow", this, true);
+ global.removeEventListener("unload", this);
+ global.removeEventListener("HiddenPlugin", this, true);
+
+ global.removeMessageListener("BrowserPlugins:ActivatePlugins", this);
+ global.removeMessageListener("BrowserPlugins:NotificationShown", this);
+ global.removeMessageListener("BrowserPlugins:ContextMenuCommand", this);
+ global.removeMessageListener("BrowserPlugins:NPAPIPluginProcessCrashed", this);
+ global.removeMessageListener("BrowserPlugins:CrashReportSubmitted", this);
+ global.removeMessageListener("BrowserPlugins:Test:ClearCrashData", this);
+
+ Services.obs.removeObserver(this, "decoder-doctor-notification");
+
+ delete this.global;
+ delete this.content;
+ },
+
+ receiveMessage: function (msg) {
+ switch (msg.name) {
+ case "BrowserPlugins:ActivatePlugins":
+ this.activatePlugins(msg.data.pluginInfo, msg.data.newState);
+ break;
+ case "BrowserPlugins:NotificationShown":
+ setTimeout(() => this.updateNotificationUI(), 0);
+ break;
+ case "BrowserPlugins:ContextMenuCommand":
+ switch (msg.data.command) {
+ case "play":
+ this._showClickToPlayNotification(msg.objects.plugin, true);
+ break;
+ case "hide":
+ this.hideClickToPlayOverlay(msg.objects.plugin);
+ break;
+ }
+ break;
+ case "BrowserPlugins:NPAPIPluginProcessCrashed":
+ this.NPAPIPluginProcessCrashed({
+ pluginName: msg.data.pluginName,
+ runID: msg.data.runID,
+ state: msg.data.state,
+ });
+ break;
+ case "BrowserPlugins:CrashReportSubmitted":
+ this.NPAPIPluginCrashReportSubmitted({
+ runID: msg.data.runID,
+ state: msg.data.state,
+ })
+ break;
+ case "BrowserPlugins:Test:ClearCrashData":
+ // This message should ONLY ever be sent by automated tests.
+ if (Services.prefs.getBoolPref("plugins.testmode")) {
+ this.pluginCrashData.clear();
+ }
+ }
+ },
+
+ observe: function observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "decoder-doctor-notification":
+ let data = JSON.parse(aData);
+ if (this.haveShownNotification &&
+ aSubject.top.document == this.content.document &&
+ data.formats.toLowerCase().includes("application/x-mpegurl", 0)) {
+ let principal = this.content.document.nodePrincipal;
+ let location = this.content.document.location.href;
+ this.global.content.pluginRequiresReload = true;
+ this.global.sendAsyncMessage("PluginContent:ShowClickToPlayNotification",
+ { plugins: [... this.pluginData.values()],
+ showNow: true,
+ location: location,
+ }, null, principal);
+ }
+ }
+ },
+
+ onPageShow: function (event) {
+ // Ignore events that aren't from the main document.
+ if (!this.content || event.target != this.content.document) {
+ return;
+ }
+
+ // The PluginClickToPlay events are not fired when navigating using the
+ // BF cache. |persisted| is true when the page is loaded from the
+ // BF cache, so this code reshows the notification if necessary.
+ if (event.persisted) {
+ this.reshowClickToPlayNotification();
+ }
+ },
+
+ onPageHide: function (event) {
+ // Ignore events that aren't from the main document.
+ if (!this.content || event.target != this.content.document) {
+ return;
+ }
+
+ this._finishRecordingFlashPluginTelemetry();
+ this.clearPluginCaches();
+ this.haveShownNotification = false;
+ },
+
+ getPluginUI: function (plugin, anonid) {
+ return plugin.ownerDocument.
+ getAnonymousElementByAttribute(plugin, "anonid", anonid);
+ },
+
+ _getPluginInfo: function (pluginElement) {
+ if (pluginElement instanceof Ci.nsIDOMHTMLAnchorElement) {
+ // Anchor elements are our place holders, and we only have them for Flash
+ let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
+ return {
+ pluginName: "Shockwave Flash",
+ mimetype: FLASH_MIME_TYPE,
+ permissionString: pluginHost.getPermissionStringForType(FLASH_MIME_TYPE)
+ };
+ }
+ let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
+ pluginElement.QueryInterface(Ci.nsIObjectLoadingContent);
+
+ let tagMimetype;
+ let pluginName = gNavigatorBundle.GetStringFromName("pluginInfo.unknownPlugin");
+ let pluginTag = null;
+ let permissionString = null;
+ let fallbackType = null;
+ let blocklistState = null;
+
+ tagMimetype = pluginElement.actualType;
+ if (tagMimetype == "") {
+ tagMimetype = pluginElement.type;
+ }
+
+ if (this.isKnownPlugin(pluginElement)) {
+ pluginTag = pluginHost.getPluginTagForType(pluginElement.actualType);
+ pluginName = BrowserUtils.makeNicePluginName(pluginTag.name);
+
+ // Convert this from nsIPluginTag so it can be serialized.
+ let properties = ["name", "description", "filename", "version", "enabledState", "niceName"];
+ let pluginTagCopy = {};
+ for (let prop of properties) {
+ pluginTagCopy[prop] = pluginTag[prop];
+ }
+ pluginTag = pluginTagCopy;
+
+ permissionString = pluginHost.getPermissionStringForType(pluginElement.actualType);
+ fallbackType = pluginElement.defaultFallbackType;
+ blocklistState = pluginHost.getBlocklistStateForType(pluginElement.actualType);
+ // Make state-softblocked == state-notblocked for our purposes,
+ // they have the same UI. STATE_OUTDATED should not exist for plugin
+ // items, but let's alias it anyway, just in case.
+ if (blocklistState == Ci.nsIBlocklistService.STATE_SOFTBLOCKED ||
+ blocklistState == Ci.nsIBlocklistService.STATE_OUTDATED) {
+ blocklistState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ }
+ }
+
+ return { mimetype: tagMimetype,
+ pluginName: pluginName,
+ pluginTag: pluginTag,
+ permissionString: permissionString,
+ fallbackType: fallbackType,
+ blocklistState: blocklistState,
+ };
+ },
+
+ _getPluginInfoForTag: function (pluginTag, tagMimetype) {
+ let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
+
+ let pluginName = gNavigatorBundle.GetStringFromName("pluginInfo.unknownPlugin");
+ let permissionString = null;
+ let blocklistState = null;
+
+ if (pluginTag) {
+ pluginName = BrowserUtils.makeNicePluginName(pluginTag.name);
+
+ permissionString = pluginHost.getPermissionStringForTag(pluginTag);
+ blocklistState = pluginTag.blocklistState;
+
+ // Convert this from nsIPluginTag so it can be serialized.
+ let properties = ["name", "description", "filename", "version", "enabledState", "niceName"];
+ let pluginTagCopy = {};
+ for (let prop of properties) {
+ pluginTagCopy[prop] = pluginTag[prop];
+ }
+ pluginTag = pluginTagCopy;
+
+ // Make state-softblocked == state-notblocked for our purposes,
+ // they have the same UI. STATE_OUTDATED should not exist for plugin
+ // items, but let's alias it anyway, just in case.
+ if (blocklistState == Ci.nsIBlocklistService.STATE_SOFTBLOCKED ||
+ blocklistState == Ci.nsIBlocklistService.STATE_OUTDATED) {
+ blocklistState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ }
+ }
+
+ return { mimetype: tagMimetype,
+ pluginName: pluginName,
+ pluginTag: pluginTag,
+ permissionString: permissionString,
+ fallbackType: null,
+ blocklistState: blocklistState,
+ };
+ },
+
+ /**
+ * Update the visibility of the plugin overlay.
+ */
+ setVisibility : function (plugin, overlay, shouldShow) {
+ overlay.classList.toggle("visible", shouldShow);
+ if (shouldShow) {
+ overlay.removeAttribute("dismissed");
+ }
+ },
+
+ /**
+ * Check whether the plugin should be visible on the page. A plugin should
+ * not be visible if the overlay is too big, or if any other page content
+ * overlays it.
+ *
+ * This function will handle showing or hiding the overlay.
+ * @returns true if the plugin is invisible.
+ */
+ shouldShowOverlay : function (plugin, overlay) {
+ // If the overlay size is 0, we haven't done layout yet. Presume that
+ // plugins are visible until we know otherwise.
+ if (overlay.scrollWidth == 0) {
+ return true;
+ }
+
+ // Is the <object>'s size too small to hold what we want to show?
+ let pluginRect = plugin.getBoundingClientRect();
+ // XXX bug 446693. The text-shadow on the submitted-report text at
+ // the bottom causes scrollHeight to be larger than it should be.
+ let overflows = (overlay.scrollWidth > Math.ceil(pluginRect.width)) ||
+ (overlay.scrollHeight - 5 > Math.ceil(pluginRect.height));
+ if (overflows) {
+ return false;
+ }
+
+ // Is the plugin covered up by other content so that it is not clickable?
+ // Floating point can confuse .elementFromPoint, so inset just a bit
+ let left = pluginRect.left + 2;
+ let right = pluginRect.right - 2;
+ let top = pluginRect.top + 2;
+ let bottom = pluginRect.bottom - 2;
+ let centerX = left + (right - left) / 2;
+ let centerY = top + (bottom - top) / 2;
+ let points = [[left, top],
+ [left, bottom],
+ [right, top],
+ [right, bottom],
+ [centerX, centerY]];
+
+ if (right <= 0 || top <= 0) {
+ return false;
+ }
+
+ let contentWindow = plugin.ownerGlobal;
+ let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+
+ for (let [x, y] of points) {
+ let el = cwu.elementFromPoint(x, y, true, true);
+ if (el !== plugin) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ addLinkClickCallback: function (linkNode, callbackName /* callbackArgs...*/) {
+ // XXX just doing (callback)(arg) was giving a same-origin error. bug?
+ let self = this;
+ let callbackArgs = Array.prototype.slice.call(arguments).slice(2);
+ linkNode.addEventListener("click",
+ function(evt) {
+ if (!evt.isTrusted)
+ return;
+ evt.preventDefault();
+ if (callbackArgs.length == 0)
+ callbackArgs = [ evt ];
+ (self[callbackName]).apply(self, callbackArgs);
+ },
+ true);
+
+ linkNode.addEventListener("keydown",
+ function(evt) {
+ if (!evt.isTrusted)
+ return;
+ if (evt.keyCode == evt.DOM_VK_RETURN) {
+ evt.preventDefault();
+ if (callbackArgs.length == 0)
+ callbackArgs = [ evt ];
+ evt.preventDefault();
+ (self[callbackName]).apply(self, callbackArgs);
+ }
+ },
+ true);
+ },
+
+ // Helper to get the binding handler type from a plugin object
+ _getBindingType : function(plugin) {
+ if (!(plugin instanceof Ci.nsIObjectLoadingContent))
+ return null;
+
+ switch (plugin.pluginFallbackType) {
+ case Ci.nsIObjectLoadingContent.PLUGIN_UNSUPPORTED:
+ return "PluginNotFound";
+ case Ci.nsIObjectLoadingContent.PLUGIN_DISABLED:
+ return "PluginDisabled";
+ case Ci.nsIObjectLoadingContent.PLUGIN_BLOCKLISTED:
+ return "PluginBlocklisted";
+ case Ci.nsIObjectLoadingContent.PLUGIN_OUTDATED:
+ return "PluginOutdated";
+ case Ci.nsIObjectLoadingContent.PLUGIN_CLICK_TO_PLAY:
+ return "PluginClickToPlay";
+ case Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_UPDATABLE:
+ return "PluginVulnerableUpdatable";
+ case Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_NO_UPDATE:
+ return "PluginVulnerableNoUpdate";
+ default:
+ // Not all states map to a handler
+ return null;
+ }
+ },
+
+ handleEvent: function (event) {
+ let eventType = event.type;
+
+ if (eventType == "unload") {
+ this.uninit();
+ return;
+ }
+
+ if (eventType == "pagehide") {
+ this.onPageHide(event);
+ return;
+ }
+
+ if (eventType == "pageshow") {
+ this.onPageShow(event);
+ return;
+ }
+
+ if (eventType == "PluginRemoved") {
+ this.updateNotificationUI(event.target);
+ return;
+ }
+
+ if (eventType == "click") {
+ this.onOverlayClick(event);
+ return;
+ }
+
+ if (eventType == "PluginCrashed" &&
+ !(event.target instanceof Ci.nsIObjectLoadingContent)) {
+ // If the event target is not a plugin object (i.e., an <object> or
+ // <embed> element), this call is for a window-global plugin.
+ this.onPluginCrashed(event.target, event);
+ return;
+ }
+
+ if (eventType == "HiddenPlugin") {
+ let win = event.target.defaultView;
+ if (!win.mozHiddenPluginTouched) {
+ let pluginTag = event.tag.QueryInterface(Ci.nsIPluginTag);
+ if (win.top.document != this.content.document) {
+ return;
+ }
+ this._showClickToPlayNotification(pluginTag, false);
+ let winUtils = win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
+ try {
+ winUtils.loadSheet(REPLACEMENT_STYLE_SHEET, win.AGENT_SHEET);
+ win.mozHiddenPluginTouched = true;
+ } catch (e) {
+ Cu.reportError("Error adding plugin replacement style sheet: " + e);
+ }
+ }
+ }
+
+ let plugin = event.target;
+
+ if (eventType == "PluginPlaceholderReplaced") {
+ plugin.removeAttribute("href");
+ let overlay = this.getPluginUI(plugin, "main");
+ this.setVisibility(plugin, overlay, true);
+ let inIDOMUtils = Cc["@mozilla.org/inspector/dom-utils;1"]
+ .getService(Ci.inIDOMUtils);
+ // Add psuedo class so our styling will take effect
+ inIDOMUtils.addPseudoClassLock(plugin, "-moz-handler-clicktoplay");
+ overlay.addEventListener("click", this, true);
+ return;
+ }
+
+ if (!(plugin instanceof Ci.nsIObjectLoadingContent))
+ return;
+
+ if (eventType == "PluginBindingAttached") {
+ // The plugin binding fires this event when it is created.
+ // As an untrusted event, ensure that this object actually has a binding
+ // and make sure we don't handle it twice
+ let overlay = this.getPluginUI(plugin, "main");
+ if (!overlay || overlay._bindingHandled) {
+ return;
+ }
+ overlay._bindingHandled = true;
+
+ // Lookup the handler for this binding
+ eventType = this._getBindingType(plugin);
+ if (!eventType) {
+ // Not all bindings have handlers
+ return;
+ }
+ }
+
+ let shouldShowNotification = false;
+ switch (eventType) {
+ case "PluginCrashed":
+ this.onPluginCrashed(plugin, event);
+ break;
+
+ case "PluginNotFound": {
+ /* NOP */
+ break;
+ }
+
+ case "PluginBlocklisted":
+ case "PluginOutdated":
+ shouldShowNotification = true;
+ break;
+
+ case "PluginVulnerableUpdatable":
+ let updateLink = this.getPluginUI(plugin, "checkForUpdatesLink");
+ let { pluginTag } = this._getPluginInfo(plugin);
+ this.addLinkClickCallback(updateLink, "forwardCallback",
+ "openPluginUpdatePage", pluginTag);
+ /* FALLTHRU */
+
+ case "PluginVulnerableNoUpdate":
+ case "PluginClickToPlay":
+ this._handleClickToPlayEvent(plugin);
+ let pluginName = this._getPluginInfo(plugin).pluginName;
+ let messageString = gNavigatorBundle.formatStringFromName("PluginClickToActivate", [pluginName], 1);
+ let overlayText = this.getPluginUI(plugin, "clickToPlay");
+ overlayText.textContent = messageString;
+ if (eventType == "PluginVulnerableUpdatable" ||
+ eventType == "PluginVulnerableNoUpdate") {
+ let vulnerabilityString = gNavigatorBundle.GetStringFromName(eventType);
+ let vulnerabilityText = this.getPluginUI(plugin, "vulnerabilityStatus");
+ vulnerabilityText.textContent = vulnerabilityString;
+ }
+ shouldShowNotification = true;
+ break;
+
+ case "PluginDisabled":
+ let manageLink = this.getPluginUI(plugin, "managePluginsLink");
+ this.addLinkClickCallback(manageLink, "forwardCallback", "managePlugins");
+ shouldShowNotification = true;
+ break;
+
+ case "PluginInstantiated":
+ let key = this._getPluginInfo(plugin).pluginTag.niceName;
+ Services.telemetry.getKeyedHistogramById('PLUGIN_ACTIVATION_COUNT').add(key);
+ shouldShowNotification = true;
+ let pluginRect = plugin.getBoundingClientRect();
+ if (pluginRect.width <= 5 && pluginRect.height <= 5) {
+ Services.telemetry.getHistogramById('PLUGIN_TINY_CONTENT').add(1);
+ }
+ break;
+ }
+
+ if (this._getPluginInfo(plugin).mimetype === FLASH_MIME_TYPE) {
+ this._recordFlashPluginTelemetry(eventType, plugin);
+ }
+
+ // Show the in-content UI if it's not too big. The crashed plugin handler already did this.
+ let overlay = this.getPluginUI(plugin, "main");
+ if (eventType != "PluginCrashed") {
+ if (overlay != null) {
+ this.setVisibility(plugin, overlay,
+ this.shouldShowOverlay(plugin, overlay));
+ let resizeListener = (event) => {
+ this.setVisibility(plugin, overlay,
+ this.shouldShowOverlay(plugin, overlay));
+ this.updateNotificationUI();
+ };
+ plugin.addEventListener("overflow", resizeListener);
+ plugin.addEventListener("underflow", resizeListener);
+ }
+ }
+
+ let closeIcon = this.getPluginUI(plugin, "closeIcon");
+ if (closeIcon) {
+ closeIcon.addEventListener("click", event => {
+ if (event.button == 0 && event.isTrusted) {
+ this.hideClickToPlayOverlay(plugin);
+ overlay.setAttribute("dismissed", "true");
+ }
+ }, true);
+ }
+
+ if (shouldShowNotification) {
+ this._showClickToPlayNotification(plugin, false);
+ }
+ },
+
+ _recordFlashPluginTelemetry: function (eventType, plugin) {
+ if (!Services.telemetry.canRecordExtended) {
+ return;
+ }
+
+ if (!this.flashPluginStats) {
+ this.flashPluginStats = {
+ instancesCount: 0,
+ plugins: new WeakSet()
+ };
+ }
+
+ if (!this.flashPluginStats.plugins.has(plugin)) {
+ // Reporting plugin instance and its dimensions only once.
+ this.flashPluginStats.plugins.add(plugin);
+
+ this.flashPluginStats.instancesCount++;
+
+ let pluginRect = plugin.getBoundingClientRect();
+ Services.telemetry.getHistogramById('FLASH_PLUGIN_WIDTH')
+ .add(pluginRect.width);
+ Services.telemetry.getHistogramById('FLASH_PLUGIN_HEIGHT')
+ .add(pluginRect.height);
+ Services.telemetry.getHistogramById('FLASH_PLUGIN_AREA')
+ .add(pluginRect.width * pluginRect.height);
+
+ let state = this._getPluginInfo(plugin).fallbackType;
+ if (state === null) {
+ state = Ci.nsIObjectLoadingContent.PLUGIN_UNSUPPORTED;
+ }
+ Services.telemetry.getHistogramById('FLASH_PLUGIN_STATES')
+ .add(state);
+ }
+ },
+
+ _finishRecordingFlashPluginTelemetry: function () {
+ if (this.flashPluginStats) {
+ Services.telemetry.getHistogramById('FLASH_PLUGIN_INSTANCES_ON_PAGE')
+ .add(this.flashPluginStats.instancesCount);
+ delete this.flashPluginStats;
+ }
+ },
+
+ isKnownPlugin: function (objLoadingContent) {
+ return (objLoadingContent.getContentTypeForMIMEType(objLoadingContent.actualType) ==
+ Ci.nsIObjectLoadingContent.TYPE_PLUGIN);
+ },
+
+ canActivatePlugin: function (objLoadingContent) {
+ // if this isn't a known plugin, we can't activate it
+ // (this also guards pluginHost.getPermissionStringForType against
+ // unexpected input)
+ if (!this.isKnownPlugin(objLoadingContent))
+ return false;
+
+ let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
+ let permissionString = pluginHost.getPermissionStringForType(objLoadingContent.actualType);
+ let principal = objLoadingContent.ownerGlobal.top.document.nodePrincipal;
+ let pluginPermission = Services.perms.testPermissionFromPrincipal(principal, permissionString);
+
+ let isFallbackTypeValid =
+ objLoadingContent.pluginFallbackType >= Ci.nsIObjectLoadingContent.PLUGIN_CLICK_TO_PLAY &&
+ objLoadingContent.pluginFallbackType <= Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_NO_UPDATE;
+
+ return !objLoadingContent.activated &&
+ pluginPermission != Ci.nsIPermissionManager.DENY_ACTION &&
+ isFallbackTypeValid;
+ },
+
+ hideClickToPlayOverlay: function (plugin) {
+ let overlay = this.getPluginUI(plugin, "main");
+ if (overlay) {
+ overlay.classList.remove("visible");
+ }
+ },
+
+ // Forward a link click callback to the chrome process.
+ forwardCallback: function (name, pluginTag) {
+ this.global.sendAsyncMessage("PluginContent:LinkClickCallback",
+ { name, pluginTag });
+ },
+
+ submitReport: function submitReport(plugin) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+ if (!plugin) {
+ Cu.reportError("Attempted to submit crash report without an associated plugin.");
+ return;
+ }
+ if (!(plugin instanceof Ci.nsIObjectLoadingContent)) {
+ Cu.reportError("Attempted to submit crash report on plugin that does not" +
+ "implement nsIObjectLoadingContent.");
+ return;
+ }
+
+ let runID = plugin.runID;
+ let submitURLOptIn = this.getPluginUI(plugin, "submitURLOptIn").checked;
+ let keyVals = {};
+ let userComment = this.getPluginUI(plugin, "submitComment").value.trim();
+ if (userComment)
+ keyVals.PluginUserComment = userComment;
+ if (submitURLOptIn)
+ keyVals.PluginContentURL = plugin.ownerDocument.URL;
+
+ this.global.sendAsyncMessage("PluginContent:SubmitReport",
+ { runID, keyVals, submitURLOptIn });
+ },
+
+ reloadPage: function () {
+ this.global.content.location.reload();
+ },
+
+ // Event listener for click-to-play plugins.
+ _handleClickToPlayEvent: function (plugin) {
+ let doc = plugin.ownerDocument;
+ let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
+ let permissionString;
+ if (plugin instanceof Ci.nsIDOMHTMLAnchorElement) {
+ // We only have replacement content for Flash installs
+ permissionString = pluginHost.getPermissionStringForType(FLASH_MIME_TYPE);
+ } else {
+ let objLoadingContent = plugin.QueryInterface(Ci.nsIObjectLoadingContent);
+ // guard against giving pluginHost.getPermissionStringForType a type
+ // not associated with any known plugin
+ if (!this.isKnownPlugin(objLoadingContent))
+ return;
+ permissionString = pluginHost.getPermissionStringForType(objLoadingContent.actualType);
+ }
+
+ let principal = doc.defaultView.top.document.nodePrincipal;
+ let pluginPermission = Services.perms.testPermissionFromPrincipal(principal, permissionString);
+
+ let overlay = this.getPluginUI(plugin, "main");
+
+ if (pluginPermission == Ci.nsIPermissionManager.DENY_ACTION) {
+ if (overlay) {
+ overlay.classList.remove("visible");
+ }
+ return;
+ }
+
+ if (overlay) {
+ overlay.addEventListener("click", this, true);
+ }
+ },
+
+ onOverlayClick: function (event) {
+ let document = event.target.ownerDocument;
+ let plugin = document.getBindingParent(event.target);
+ let contentWindow = plugin.ownerGlobal.top;
+ let overlay = this.getPluginUI(plugin, "main");
+ // Have to check that the target is not the link to update the plugin
+ if (!(event.originalTarget instanceof contentWindow.HTMLAnchorElement) &&
+ (event.originalTarget.getAttribute('anonid') != 'closeIcon') &&
+ !overlay.hasAttribute('dismissed') &&
+ event.button == 0 &&
+ event.isTrusted) {
+ this._showClickToPlayNotification(plugin, true);
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ },
+
+ reshowClickToPlayNotification: function () {
+ let contentWindow = this.global.content;
+ let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ let plugins = cwu.plugins;
+ for (let plugin of plugins) {
+ let overlay = this.getPluginUI(plugin, "main");
+ if (overlay)
+ overlay.removeEventListener("click", this, true);
+ let objLoadingContent = plugin.QueryInterface(Ci.nsIObjectLoadingContent);
+ if (this.canActivatePlugin(objLoadingContent))
+ this._handleClickToPlayEvent(plugin);
+ }
+ this._showClickToPlayNotification(null, false);
+ },
+
+ /**
+ * Activate the plugins that the user has specified.
+ */
+ activatePlugins: function (pluginInfo, newState) {
+ let contentWindow = this.global.content;
+ let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ let plugins = cwu.plugins;
+ let pluginHost = Cc["@mozilla.org/plugin/host;1"].getService(Ci.nsIPluginHost);
+
+ let pluginFound = false;
+ let placeHolderFound = false;
+ for (let plugin of plugins) {
+ plugin.QueryInterface(Ci.nsIObjectLoadingContent);
+ if (!this.isKnownPlugin(plugin)) {
+ continue;
+ }
+ if (pluginInfo.permissionString == pluginHost.getPermissionStringForType(plugin.actualType)) {
+ let overlay = this.getPluginUI(plugin, "main");
+ if (plugin instanceof Ci.nsIDOMHTMLAnchorElement) {
+ placeHolderFound = true;
+ } else {
+ pluginFound = true;
+ }
+ if (newState == "block") {
+ if (overlay) {
+ overlay.addEventListener("click", this, true);
+ }
+ plugin.reload(true);
+ } else if (this.canActivatePlugin(plugin)) {
+ if (overlay) {
+ overlay.removeEventListener("click", this, true);
+ }
+ plugin.playPlugin();
+ }
+ }
+ }
+
+ // If there are no instances of the plugin on the page any more, what the
+ // user probably needs is for us to allow and then refresh. Additionally, if
+ // this is content that requires HLS or we replaced the placeholder the page
+ // needs to be refreshed for it to insert its plugins
+ if (newState != "block" &&
+ (!pluginFound || placeHolderFound || contentWindow.pluginRequiresReload)) {
+ this.reloadPage();
+ }
+ this.updateNotificationUI();
+ },
+
+ _showClickToPlayNotification: function (plugin, showNow) {
+ let plugins = [];
+
+ // If plugin is null, that means the user has navigated back to a page with
+ // plugins, and we need to collect all the plugins.
+ if (plugin === null) {
+ let contentWindow = this.content;
+ let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ // cwu.plugins may contain non-plugin <object>s, filter them out
+ plugins = cwu.plugins.filter((plugin) =>
+ plugin.getContentTypeForMIMEType(plugin.actualType) == Ci.nsIObjectLoadingContent.TYPE_PLUGIN);
+
+ if (plugins.length == 0) {
+ this.removeNotification("click-to-play-plugins");
+ return;
+ }
+ } else {
+ plugins = [plugin];
+ }
+
+ let pluginData = this.pluginData;
+
+ let principal = this.content.document.nodePrincipal;
+ let location = this.content.document.location.href;
+
+ for (let p of plugins) {
+ let pluginInfo;
+ if (p instanceof Ci.nsIPluginTag) {
+ let mimeType = p.getMimeTypes() > 0 ? p.getMimeTypes()[0] : null;
+ pluginInfo = this._getPluginInfoForTag(p, mimeType);
+ } else {
+ pluginInfo = this._getPluginInfo(p);
+ }
+ if (pluginInfo.permissionString === null) {
+ Cu.reportError("No permission string for active plugin.");
+ continue;
+ }
+ if (pluginData.has(pluginInfo.permissionString)) {
+ continue;
+ }
+
+ let permissionObj = Services.perms.
+ getPermissionObject(principal, pluginInfo.permissionString, false);
+ if (permissionObj) {
+ pluginInfo.pluginPermissionPrePath = permissionObj.principal.originNoSuffix;
+ pluginInfo.pluginPermissionType = permissionObj.expireType;
+ }
+ else {
+ pluginInfo.pluginPermissionPrePath = principal.originNoSuffix;
+ pluginInfo.pluginPermissionType = undefined;
+ }
+
+ this.pluginData.set(pluginInfo.permissionString, pluginInfo);
+ }
+
+ this.haveShownNotification = true;
+
+ this.global.sendAsyncMessage("PluginContent:ShowClickToPlayNotification", {
+ plugins: [... this.pluginData.values()],
+ showNow: showNow,
+ location: location,
+ }, null, principal);
+ },
+
+ /**
+ * Updates the "hidden plugin" notification bar UI.
+ *
+ * @param document (optional)
+ * Specify the document that is causing the update.
+ * This is useful when the document is possibly no longer
+ * the current loaded document (for example, if we're
+ * responding to a PluginRemoved event for an unloading
+ * document). If this parameter is omitted, it defaults
+ * to the current top-level document.
+ */
+ updateNotificationUI: function (document) {
+ document = document || this.content.document;
+
+ // We're only interested in the top-level document, since that's
+ // the one that provides the Principal that we send back to the
+ // parent.
+ let principal = document.defaultView.top.document.nodePrincipal;
+ let location = document.location.href;
+
+ // Make a copy of the actions from the last popup notification.
+ let haveInsecure = false;
+ let actions = new Map();
+ for (let action of this.pluginData.values()) {
+ switch (action.fallbackType) {
+ // haveInsecure will trigger the red flashing icon and the infobar
+ // styling below
+ case Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_UPDATABLE:
+ case Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_NO_UPDATE:
+ haveInsecure = true;
+ // fall through
+
+ case Ci.nsIObjectLoadingContent.PLUGIN_CLICK_TO_PLAY:
+ actions.set(action.permissionString, action);
+ continue;
+ }
+ }
+
+ // Remove plugins that are already active, or large enough to show an overlay.
+ let cwu = this.content.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ for (let plugin of cwu.plugins) {
+ let info = this._getPluginInfo(plugin);
+ if (!actions.has(info.permissionString)) {
+ continue;
+ }
+ let fallbackType = info.fallbackType;
+ if (fallbackType == Ci.nsIObjectLoadingContent.PLUGIN_ACTIVE) {
+ actions.delete(info.permissionString);
+ if (actions.size == 0) {
+ break;
+ }
+ continue;
+ }
+ if (fallbackType != Ci.nsIObjectLoadingContent.PLUGIN_CLICK_TO_PLAY &&
+ fallbackType != Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_UPDATABLE &&
+ fallbackType != Ci.nsIObjectLoadingContent.PLUGIN_VULNERABLE_NO_UPDATE) {
+ continue;
+ }
+ let overlay = this.getPluginUI(plugin, "main");
+ if (!overlay) {
+ continue;
+ }
+ let shouldShow = this.shouldShowOverlay(plugin, overlay);
+ this.setVisibility(plugin, overlay, shouldShow);
+ if (shouldShow) {
+ actions.delete(info.permissionString);
+ if (actions.size == 0) {
+ break;
+ }
+ }
+ }
+
+ // If there are any items remaining in `actions` now, they are hidden
+ // plugins that need a notification bar.
+ this.global.sendAsyncMessage("PluginContent:UpdateHiddenPluginUI", {
+ haveInsecure: haveInsecure,
+ actions: [... actions.values()],
+ location: location,
+ }, null, principal);
+ },
+
+ removeNotification: function (name) {
+ this.global.sendAsyncMessage("PluginContent:RemoveNotification", { name: name });
+ },
+
+ clearPluginCaches: function () {
+ this.pluginData.clear();
+ this.pluginCrashData.clear();
+ },
+
+ hideNotificationBar: function (name) {
+ this.global.sendAsyncMessage("PluginContent:HideNotificationBar", { name: name });
+ },
+
+ /**
+ * The PluginCrashed event handler. Note that the PluginCrashed event is
+ * fired for both NPAPI and Gecko Media plugins. In the latter case, the
+ * target of the event is the document that the GMP is being used in.
+ */
+ onPluginCrashed: function (target, aEvent) {
+ if (!(aEvent instanceof this.content.PluginCrashedEvent))
+ return;
+
+ if (aEvent.gmpPlugin) {
+ this.GMPCrashed(aEvent);
+ return;
+ }
+
+ if (!(target instanceof Ci.nsIObjectLoadingContent))
+ return;
+
+ let crashData = this.pluginCrashData.get(target.runID);
+ if (!crashData) {
+ // We haven't received information from the parent yet about
+ // this crash, so we should hold off showing the crash report
+ // UI.
+ return;
+ }
+
+ crashData.instances.delete(target);
+ if (crashData.instances.length == 0) {
+ this.pluginCrashData.delete(target.runID);
+ }
+
+ this.setCrashedNPAPIPluginState({
+ plugin: target,
+ state: crashData.state,
+ message: crashData.message,
+ });
+ },
+
+ NPAPIPluginProcessCrashed: function ({pluginName, runID, state}) {
+ let message =
+ gNavigatorBundle.formatStringFromName("crashedpluginsMessage.title",
+ [pluginName], 1);
+
+ let contentWindow = this.global.content;
+ let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ let plugins = cwu.plugins;
+
+ for (let plugin of plugins) {
+ if (plugin instanceof Ci.nsIObjectLoadingContent &&
+ plugin.runID == runID) {
+ // The parent has told us that the plugin process has died.
+ // It's possible that this content process hasn't yet noticed,
+ // in which case we need to stash this data around until the
+ // PluginCrashed events get sent up.
+ if (plugin.pluginFallbackType == Ci.nsIObjectLoadingContent.PLUGIN_CRASHED) {
+ // This plugin has already been put into the crashed state by the
+ // content process, so we can tweak its crash UI without delay.
+ this.setCrashedNPAPIPluginState({plugin, state, message});
+ } else {
+ // The content process hasn't yet determined that the plugin has crashed.
+ // Stash the data in our map, and throw the plugin into a WeakSet. When
+ // the PluginCrashed event fires on the <object>/<embed>, we'll retrieve
+ // the information we need from the Map and remove the instance from the
+ // WeakSet. Once the WeakSet is empty, we can clear the map.
+ if (!this.pluginCrashData.has(runID)) {
+ this.pluginCrashData.set(runID, {
+ state: state,
+ message: message,
+ instances: new WeakSet(),
+ });
+ }
+ let crashData = this.pluginCrashData.get(runID);
+ crashData.instances.add(plugin);
+ }
+ }
+ }
+ },
+
+ setCrashedNPAPIPluginState: function ({plugin, state, message}) {
+ // Force a layout flush so the binding is attached.
+ plugin.clientTop;
+ let overlay = this.getPluginUI(plugin, "main");
+ let statusDiv = this.getPluginUI(plugin, "submitStatus");
+ let optInCB = this.getPluginUI(plugin, "submitURLOptIn");
+
+ this.getPluginUI(plugin, "submitButton")
+ .addEventListener("click", (event) => {
+ if (event.button != 0 || !event.isTrusted)
+ return;
+ this.submitReport(plugin);
+ });
+
+ let pref = Services.prefs.getBranch("dom.ipc.plugins.reportCrashURL");
+ optInCB.checked = pref.getBoolPref("");
+
+ statusDiv.setAttribute("status", state);
+
+ let helpIcon = this.getPluginUI(plugin, "helpIcon");
+ this.addLinkClickCallback(helpIcon, "openHelpPage");
+
+ let crashText = this.getPluginUI(plugin, "crashedText");
+ crashText.textContent = message;
+
+ let link = this.getPluginUI(plugin, "reloadLink");
+ this.addLinkClickCallback(link, "reloadPage");
+
+ let isShowing = this.shouldShowOverlay(plugin, overlay);
+
+ // Is the <object>'s size too small to hold what we want to show?
+ if (!isShowing) {
+ // First try hiding the crash report submission UI.
+ statusDiv.removeAttribute("status");
+
+ isShowing = this.shouldShowOverlay(plugin, overlay);
+ }
+ this.setVisibility(plugin, overlay, isShowing);
+
+ let doc = plugin.ownerDocument;
+ let runID = plugin.runID;
+
+ if (isShowing) {
+ // If a previous plugin on the page was too small and resulted in adding a
+ // notification bar, then remove it because this plugin instance it big
+ // enough to serve as in-content notification.
+ this.hideNotificationBar("plugin-crashed");
+ doc.mozNoPluginCrashedNotification = true;
+
+ // Notify others that the crash reporter UI is now ready.
+ // Currently, this event is only used by tests.
+ let winUtils = this.content.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ let event = new this.content.CustomEvent("PluginCrashReporterDisplayed", {bubbles: true});
+ winUtils.dispatchEventToChromeOnly(plugin, event);
+ } else if (!doc.mozNoPluginCrashedNotification) {
+ // If another plugin on the page was large enough to show our UI, we don't
+ // want to show a notification bar.
+ this.global.sendAsyncMessage("PluginContent:ShowPluginCrashedNotification",
+ { messageString: message, pluginID: runID });
+ // Remove the notification when the page is reloaded.
+ doc.defaultView.top.addEventListener("unload", event => {
+ this.hideNotificationBar("plugin-crashed");
+ }, false);
+ }
+ },
+
+ NPAPIPluginCrashReportSubmitted: function({ runID, state }) {
+ this.pluginCrashData.delete(runID);
+ let contentWindow = this.global.content;
+ let cwu = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ let plugins = cwu.plugins;
+
+ for (let plugin of plugins) {
+ if (plugin instanceof Ci.nsIObjectLoadingContent &&
+ plugin.runID == runID) {
+ let statusDiv = this.getPluginUI(plugin, "submitStatus");
+ statusDiv.setAttribute("status", state);
+ }
+ }
+ },
+
+ GMPCrashed: function(aEvent) {
+ let target = aEvent.target;
+ let pluginName = aEvent.pluginName;
+ let gmpPlugin = aEvent.gmpPlugin;
+ let pluginID = aEvent.pluginID;
+ let doc = target.document;
+
+ if (!gmpPlugin || !doc) {
+ // TODO: Throw exception? How did we get here?
+ return;
+ }
+
+ let messageString =
+ gNavigatorBundle.formatStringFromName("crashedpluginsMessage.title",
+ [pluginName], 1);
+
+ this.global.sendAsyncMessage("PluginContent:ShowPluginCrashedNotification",
+ { messageString, pluginID });
+
+ // Remove the notification when the page is reloaded.
+ doc.defaultView.top.addEventListener("unload", event => {
+ this.hideNotificationBar("plugin-crashed");
+ }, false);
+ },
+};
diff --git a/browser/modules/ProcessHangMonitor.jsm b/browser/modules/ProcessHangMonitor.jsm
new file mode 100644
index 000000000..e048f5b40
--- /dev/null
+++ b/browser/modules/ProcessHangMonitor.jsm
@@ -0,0 +1,397 @@
+/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = ["ProcessHangMonitor"];
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+/**
+ * This JSM is responsible for observing content process hang reports
+ * and asking the user what to do about them. See nsIHangReport for
+ * the platform interface.
+ */
+
+var ProcessHangMonitor = {
+ /**
+ * This timeout is the wait period applied after a user selects "Wait" in
+ * an existing notification.
+ */
+ get WAIT_EXPIRATION_TIME() {
+ try {
+ return Services.prefs.getIntPref("browser.hangNotification.waitPeriod");
+ } catch (ex) {
+ return 10000;
+ }
+ },
+
+ /**
+ * Collection of hang reports that haven't expired or been dismissed
+ * by the user. These are nsIHangReports.
+ */
+ _activeReports: new Set(),
+
+ /**
+ * Collection of hang reports that have been suppressed for a short
+ * period of time. Value is an nsITimer for when the wait time
+ * expires.
+ */
+ _pausedReports: new Map(),
+
+ /**
+ * Initialize hang reporting. Called once in the parent process.
+ */
+ init: function() {
+ Services.obs.addObserver(this, "process-hang-report", false);
+ Services.obs.addObserver(this, "clear-hang-report", false);
+ Services.obs.addObserver(this, "xpcom-shutdown", false);
+ Services.ww.registerNotification(this);
+ },
+
+ /**
+ * Terminate JavaScript associated with the hang being reported for
+ * the selected browser in |win|.
+ */
+ terminateScript: function(win) {
+ this.handleUserInput(win, report => report.terminateScript());
+ },
+
+ /**
+ * Start devtools debugger for JavaScript associated with the hang
+ * being reported for the selected browser in |win|.
+ */
+ debugScript: function(win) {
+ this.handleUserInput(win, report => {
+ function callback() {
+ report.endStartingDebugger();
+ }
+
+ report.beginStartingDebugger();
+
+ let svc = Cc["@mozilla.org/dom/slow-script-debug;1"].getService(Ci.nsISlowScriptDebug);
+ let handler = svc.remoteActivationHandler;
+ handler.handleSlowScriptDebug(report.scriptBrowser, callback);
+ });
+ },
+
+ /**
+ * Terminate the plugin process associated with a hang being reported
+ * for the selected browser in |win|. Will attempt to generate a combined
+ * crash report for all processes.
+ */
+ terminatePlugin: function(win) {
+ this.handleUserInput(win, report => report.terminatePlugin());
+ },
+
+ /**
+ * Dismiss the browser notification and invoke an appropriate action based on
+ * the hang type.
+ */
+ stopIt: function (win) {
+ let report = this.findActiveReport(win.gBrowser.selectedBrowser);
+ if (!report) {
+ return;
+ }
+
+ switch (report.hangType) {
+ case report.SLOW_SCRIPT:
+ this.terminateScript(win);
+ break;
+ case report.PLUGIN_HANG:
+ this.terminatePlugin(win);
+ break;
+ }
+ },
+
+ /**
+ * Dismiss the notification, clear the report from the active list and set up
+ * a new timer to track a wait period during which we won't notify.
+ */
+ waitLonger: function(win) {
+ let report = this.findActiveReport(win.gBrowser.selectedBrowser);
+ if (!report) {
+ return;
+ }
+ // Remove the report from the active list.
+ this.removeActiveReport(report);
+
+ // NOTE, we didn't call userCanceled on nsIHangReport here. This insures
+ // we don't repeatedly generate and cache crash report data for this hang
+ // in the process hang reporter. It already has one report for the browser
+ // process we want it hold onto.
+
+ // Create a new wait timer with notify callback
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(() => {
+ for (let [stashedReport, otherTimer] of this._pausedReports) {
+ if (otherTimer === timer) {
+ this.removePausedReport(stashedReport);
+
+ // We're still hung, so move the report back to the active
+ // list and update the UI.
+ this._activeReports.add(report);
+ this.updateWindows();
+ break;
+ }
+ }
+ }, this.WAIT_EXPIRATION_TIME, timer.TYPE_ONE_SHOT);
+
+ this._pausedReports.set(report, timer);
+
+ // remove the browser notification associated with this hang
+ this.updateWindows();
+ },
+
+ /**
+ * If there is a hang report associated with the selected browser in
+ * |win|, invoke |func| on that report and stop notifying the user
+ * about it.
+ */
+ handleUserInput: function(win, func) {
+ let report = this.findActiveReport(win.gBrowser.selectedBrowser);
+ if (!report) {
+ return null;
+ }
+ this.removeActiveReport(report);
+
+ return func(report);
+ },
+
+ observe: function(subject, topic, data) {
+ switch (topic) {
+ case "xpcom-shutdown":
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ Services.obs.removeObserver(this, "process-hang-report");
+ Services.obs.removeObserver(this, "clear-hang-report");
+ Services.ww.unregisterNotification(this);
+ break;
+
+ case "process-hang-report":
+ this.reportHang(subject.QueryInterface(Ci.nsIHangReport));
+ break;
+
+ case "clear-hang-report":
+ this.clearHang(subject.QueryInterface(Ci.nsIHangReport));
+ break;
+
+ case "domwindowopened":
+ // Install event listeners on the new window in case one of
+ // its tabs is already hung.
+ let win = subject.QueryInterface(Ci.nsIDOMWindow);
+ let listener = (ev) => {
+ win.removeEventListener("load", listener, true);
+ this.updateWindows();
+ };
+ win.addEventListener("load", listener, true);
+ break;
+ }
+ },
+
+ /**
+ * Find a active hang report for the given <browser> element.
+ */
+ findActiveReport: function(browser) {
+ let frameLoader = browser.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader;
+ for (let report of this._activeReports) {
+ if (report.isReportForBrowser(frameLoader)) {
+ return report;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Find a paused hang report for the given <browser> element.
+ */
+ findPausedReport: function(browser) {
+ let frameLoader = browser.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader;
+ for (let [report, ] of this._pausedReports) {
+ if (report.isReportForBrowser(frameLoader)) {
+ return report;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Remove an active hang report from the active list and cancel the timer
+ * associated with it.
+ */
+ removeActiveReport: function(report) {
+ this._activeReports.delete(report);
+ this.updateWindows();
+ },
+
+ /**
+ * Remove a paused hang report from the paused list and cancel the timer
+ * associated with it.
+ */
+ removePausedReport: function(report) {
+ let timer = this._pausedReports.get(report);
+ if (timer) {
+ timer.cancel();
+ }
+ this._pausedReports.delete(report);
+ },
+
+ /**
+ * Iterate over all XUL windows and ensure that the proper hang
+ * reports are shown for each one. Also install event handlers in
+ * each window to watch for events that would cause a different hang
+ * report to be displayed.
+ */
+ updateWindows: function() {
+ let e = Services.wm.getEnumerator("navigator:browser");
+ while (e.hasMoreElements()) {
+ let win = e.getNext();
+
+ this.updateWindow(win);
+
+ // Only listen for these events if there are active hang reports.
+ if (this._activeReports.size) {
+ this.trackWindow(win);
+ } else {
+ this.untrackWindow(win);
+ }
+ }
+ },
+
+ /**
+ * If there is a hang report for the current tab in |win|, display it.
+ */
+ updateWindow: function(win) {
+ let report = this.findActiveReport(win.gBrowser.selectedBrowser);
+
+ if (report) {
+ this.showNotification(win, report);
+ } else {
+ this.hideNotification(win);
+ }
+ },
+
+ /**
+ * Show the notification for a hang.
+ */
+ showNotification: function(win, report) {
+ let nb = win.document.getElementById("high-priority-global-notificationbox");
+ let notification = nb.getNotificationWithValue("process-hang");
+ if (notification) {
+ return;
+ }
+
+ let bundle = win.gNavigatorBundle;
+
+ let buttons = [{
+ label: bundle.getString("processHang.button_stop.label"),
+ accessKey: bundle.getString("processHang.button_stop.accessKey"),
+ callback: function() {
+ ProcessHangMonitor.stopIt(win);
+ }
+ },
+ {
+ label: bundle.getString("processHang.button_wait.label"),
+ accessKey: bundle.getString("processHang.button_wait.accessKey"),
+ callback: function() {
+ ProcessHangMonitor.waitLonger(win);
+ }
+ }];
+
+ if (AppConstants.MOZ_DEV_EDITION && report.hangType == report.SLOW_SCRIPT) {
+ buttons.push({
+ label: bundle.getString("processHang.button_debug.label"),
+ accessKey: bundle.getString("processHang.button_debug.accessKey"),
+ callback: function() {
+ ProcessHangMonitor.debugScript(win);
+ }
+ });
+ }
+
+ nb.appendNotification(bundle.getString("processHang.label"),
+ "process-hang",
+ "chrome://browser/content/aboutRobots-icon.png",
+ nb.PRIORITY_WARNING_HIGH, buttons);
+ },
+
+ /**
+ * Ensure that no hang notifications are visible in |win|.
+ */
+ hideNotification: function(win) {
+ let nb = win.document.getElementById("high-priority-global-notificationbox");
+ let notification = nb.getNotificationWithValue("process-hang");
+ if (notification) {
+ nb.removeNotification(notification);
+ }
+ },
+
+ /**
+ * Install event handlers on |win| to watch for events that would
+ * cause a different hang report to be displayed.
+ */
+ trackWindow: function(win) {
+ win.gBrowser.tabContainer.addEventListener("TabSelect", this, true);
+ win.gBrowser.tabContainer.addEventListener("TabRemotenessChange", this, true);
+ },
+
+ untrackWindow: function(win) {
+ win.gBrowser.tabContainer.removeEventListener("TabSelect", this, true);
+ win.gBrowser.tabContainer.removeEventListener("TabRemotenessChange", this, true);
+ },
+
+ handleEvent: function(event) {
+ let win = event.target.ownerGlobal;
+
+ // If a new tab is selected or if a tab changes remoteness, then
+ // we may need to show or hide a hang notification.
+
+ if (event.type == "TabSelect" || event.type == "TabRemotenessChange") {
+ this.updateWindow(win);
+ }
+ },
+
+ /**
+ * Handle a potentially new hang report. If it hasn't been seen
+ * before, show a notification for it in all open XUL windows.
+ */
+ reportHang: function(report) {
+ // If this hang was already reported reset the timer for it.
+ if (this._activeReports.has(report)) {
+ // if this report is in active but doesn't have a notification associated
+ // with it, display a notification.
+ this.updateWindows();
+ return;
+ }
+
+ // If this hang was already reported and paused by the user ignore it.
+ if (this._pausedReports.has(report)) {
+ return;
+ }
+
+ // On e10s this counts slow-script/hanged-plugin notice only once.
+ // This code is not reached on non-e10s.
+ if (report.hangType == report.SLOW_SCRIPT) {
+ // On non-e10s, SLOW_SCRIPT_NOTICE_COUNT is probed at nsGlobalWindow.cpp
+ Services.telemetry.getHistogramById("SLOW_SCRIPT_NOTICE_COUNT").add();
+ } else if (report.hangType == report.PLUGIN_HANG) {
+ // On non-e10s we have sufficient plugin telemetry probes,
+ // so PLUGIN_HANG_NOTICE_COUNT is only probed on e10s.
+ Services.telemetry.getHistogramById("PLUGIN_HANG_NOTICE_COUNT").add();
+ }
+
+ this._activeReports.add(report);
+ this.updateWindows();
+ },
+
+ clearHang: function(report) {
+ this.removeActiveReport(report);
+ this.removePausedReport(report);
+ report.userCanceled();
+ },
+};
diff --git a/browser/modules/ReaderParent.jsm b/browser/modules/ReaderParent.jsm
new file mode 100644
index 000000000..6fcaada42
--- /dev/null
+++ b/browser/modules/ReaderParent.jsm
@@ -0,0 +1,186 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+this.EXPORTED_SYMBOLS = [ "ReaderParent" ];
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ReaderMode", "resource://gre/modules/ReaderMode.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "UITour", "resource:///modules/UITour.jsm");
+
+const gStringBundle = Services.strings.createBundle("chrome://global/locale/aboutReader.properties");
+
+var ReaderParent = {
+ _readerModeInfoPanelOpen: false,
+
+ MESSAGES: [
+ "Reader:ArticleGet",
+ "Reader:FaviconRequest",
+ "Reader:UpdateReaderButton",
+ ],
+
+ init: function() {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
+ for (let msg of this.MESSAGES) {
+ mm.addMessageListener(msg, this);
+ }
+ },
+
+ receiveMessage: function(message) {
+ switch (message.name) {
+ case "Reader:ArticleGet":
+ this._getArticle(message.data.url, message.target).then((article) => {
+ // Make sure the target browser is still alive before trying to send data back.
+ if (message.target.messageManager) {
+ message.target.messageManager.sendAsyncMessage("Reader:ArticleData", { article: article });
+ }
+ }, e => {
+ if (e && e.newURL) {
+ // Make sure the target browser is still alive before trying to send data back.
+ if (message.target.messageManager) {
+ message.target.messageManager.sendAsyncMessage("Reader:ArticleData", { newURL: e.newURL });
+ }
+ }
+ });
+ break;
+
+ case "Reader:FaviconRequest": {
+ if (message.target.messageManager) {
+ let faviconUrl = PlacesUtils.promiseFaviconLinkUrl(message.data.url);
+ faviconUrl.then(function onResolution(favicon) {
+ message.target.messageManager.sendAsyncMessage("Reader:FaviconReturn", {
+ url: message.data.url,
+ faviconUrl: favicon.path.replace(/^favicon:/, "")
+ })
+ },
+ function onRejection(reason) {
+ Cu.reportError("Error requesting favicon URL for about:reader content: " + reason);
+ }).catch(Cu.reportError);
+ }
+ break;
+ }
+
+ case "Reader:UpdateReaderButton": {
+ let browser = message.target;
+ if (message.data && message.data.isArticle !== undefined) {
+ browser.isArticle = message.data.isArticle;
+ }
+ this.updateReaderButton(browser);
+ break;
+ }
+ }
+ },
+
+ updateReaderButton: function(browser) {
+ let win = browser.ownerGlobal;
+ if (browser != win.gBrowser.selectedBrowser) {
+ return;
+ }
+
+ let button = win.document.getElementById("reader-mode-button");
+ let command = win.document.getElementById("View:ReaderView");
+ let key = win.document.getElementById("toggleReaderMode");
+ if (browser.currentURI.spec.startsWith("about:reader")) {
+ button.setAttribute("readeractive", true);
+ button.hidden = false;
+ let closeText = gStringBundle.GetStringFromName("readerView.close");
+ button.setAttribute("tooltiptext", closeText);
+ command.setAttribute("label", closeText);
+ command.setAttribute("hidden", false);
+ command.setAttribute("accesskey", gStringBundle.GetStringFromName("readerView.close.accesskey"));
+ key.setAttribute("disabled", false);
+ } else {
+ button.removeAttribute("readeractive");
+ button.hidden = !browser.isArticle;
+ let enterText = gStringBundle.GetStringFromName("readerView.enter");
+ button.setAttribute("tooltiptext", enterText);
+ command.setAttribute("label", enterText);
+ command.setAttribute("hidden", !browser.isArticle);
+ command.setAttribute("accesskey", gStringBundle.GetStringFromName("readerView.enter.accesskey"));
+ key.setAttribute("disabled", !browser.isArticle);
+ }
+
+ let currentUriHost = browser.currentURI && browser.currentURI.asciiHost;
+ if (browser.isArticle &&
+ !Services.prefs.getBoolPref("browser.reader.detectedFirstArticle") &&
+ currentUriHost && !currentUriHost.endsWith("mozilla.org")) {
+ this.showReaderModeInfoPanel(browser);
+ Services.prefs.setBoolPref("browser.reader.detectedFirstArticle", true);
+ this._readerModeInfoPanelOpen = true;
+ } else if (this._readerModeInfoPanelOpen) {
+ if (UITour.isInfoOnTarget(win, "readerMode-urlBar")) {
+ UITour.hideInfo(win);
+ }
+ this._readerModeInfoPanelOpen = false;
+ }
+ },
+
+ forceShowReaderIcon: function(browser) {
+ browser.isArticle = true;
+ this.updateReaderButton(browser);
+ },
+
+ buttonClick(event) {
+ if (event.button != 0) {
+ return;
+ }
+ this.toggleReaderMode(event);
+ },
+
+ toggleReaderMode: function(event) {
+ let win = event.target.ownerGlobal;
+ let browser = win.gBrowser.selectedBrowser;
+ browser.messageManager.sendAsyncMessage("Reader:ToggleReaderMode");
+ },
+
+ /**
+ * Shows an info panel from the UITour for Reader Mode.
+ *
+ * @param browser The <browser> that the tour should be started for.
+ */
+ showReaderModeInfoPanel(browser) {
+ let win = browser.ownerGlobal;
+ let targetPromise = UITour.getTarget(win, "readerMode-urlBar");
+ targetPromise.then(target => {
+ let browserBundle = Services.strings.createBundle("chrome://browser/locale/browser.properties");
+ let icon = "chrome://browser/skin/";
+ if (win.devicePixelRatio > 1) {
+ icon += "reader-tour@2x.png";
+ } else {
+ icon += "reader-tour.png";
+ }
+ UITour.showInfo(win, target,
+ browserBundle.GetStringFromName("readingList.promo.firstUse.readerView.title"),
+ browserBundle.GetStringFromName("readingList.promo.firstUse.readerView.body"),
+ icon);
+ });
+ },
+
+ /**
+ * Gets an article for a given URL. This method will download and parse a document.
+ *
+ * @param url The article URL.
+ * @param browser The browser where the article is currently loaded.
+ * @return {Promise}
+ * @resolves JS object representing the article, or null if no article is found.
+ */
+ _getArticle: Task.async(function* (url, browser) {
+ return yield ReaderMode.downloadAndParseDocument(url).catch(e => {
+ if (e && e.newURL) {
+ // Pass up the error so we can navigate the browser in question to the new URL:
+ throw e;
+ }
+ Cu.reportError("Error downloading and parsing document: " + e);
+ return null;
+ });
+ })
+};
diff --git a/browser/modules/RecentWindow.jsm b/browser/modules/RecentWindow.jsm
new file mode 100644
index 000000000..fac9dcea4
--- /dev/null
+++ b/browser/modules/RecentWindow.jsm
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["RecentWindow"];
+
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
+
+this.RecentWindow = {
+ /*
+ * Get the most recent browser window.
+ *
+ * @param aOptions an object accepting the arguments for the search.
+ * * private: true to restrict the search to private windows
+ * only, false to restrict the search to non-private only.
+ * Omit the property to search in both groups.
+ * * allowPopups: true if popup windows are permissable.
+ */
+ getMostRecentBrowserWindow: function RW_getMostRecentBrowserWindow(aOptions) {
+ let checkPrivacy = typeof aOptions == "object" &&
+ "private" in aOptions;
+
+ let allowPopups = typeof aOptions == "object" && !!aOptions.allowPopups;
+
+ function isSuitableBrowserWindow(win) {
+ return (!win.closed &&
+ (allowPopups || win.toolbar.visible) &&
+ (!checkPrivacy ||
+ PrivateBrowsingUtils.permanentPrivateBrowsing ||
+ PrivateBrowsingUtils.isWindowPrivate(win) == aOptions.private));
+ }
+
+ let broken_wm_z_order =
+ AppConstants.platform != "macosx" && AppConstants.platform != "win";
+
+ if (broken_wm_z_order) {
+ let win = Services.wm.getMostRecentWindow("navigator:browser");
+
+ // if we're lucky, this isn't a popup, and we can just return this
+ if (win && !isSuitableBrowserWindow(win)) {
+ win = null;
+ let windowList = Services.wm.getEnumerator("navigator:browser");
+ // this is oldest to newest, so this gets a bit ugly
+ while (windowList.hasMoreElements()) {
+ let nextWin = windowList.getNext();
+ if (isSuitableBrowserWindow(nextWin))
+ win = nextWin;
+ }
+ }
+ return win;
+ }
+ let windowList = Services.wm.getZOrderDOMWindowEnumerator("navigator:browser", true);
+ while (windowList.hasMoreElements()) {
+ let win = windowList.getNext();
+ if (isSuitableBrowserWindow(win))
+ return win;
+ }
+ return null;
+ }
+};
+
diff --git a/browser/modules/RemotePrompt.jsm b/browser/modules/RemotePrompt.jsm
new file mode 100644
index 000000000..da4945c2e
--- /dev/null
+++ b/browser/modules/RemotePrompt.jsm
@@ -0,0 +1,110 @@
+/* vim: set ts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = [ "RemotePrompt" ];
+
+Cu.import("resource:///modules/PlacesUIUtils.jsm");
+Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/SharedPromptUtils.jsm");
+
+var RemotePrompt = {
+ init: function() {
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
+ mm.addMessageListener("Prompt:Open", this);
+ },
+
+ receiveMessage: function(message) {
+ switch (message.name) {
+ case "Prompt:Open":
+ if (message.data.uri) {
+ this.openModalWindow(message.data, message.target);
+ } else {
+ this.openTabPrompt(message.data, message.target)
+ }
+ break;
+ }
+ },
+
+ openTabPrompt: function(args, browser) {
+ let window = browser.ownerGlobal;
+ let tabPrompt = window.gBrowser.getTabModalPromptBox(browser)
+ let newPrompt;
+ let needRemove = false;
+ let promptId = args._remoteId;
+
+ function onPromptClose(forceCleanup) {
+ // It's possible that we removed the prompt during the
+ // appendPrompt call below. In that case, newPrompt will be
+ // undefined. We set the needRemove flag to remember to remove
+ // it right after we've finished adding it.
+ if (newPrompt)
+ tabPrompt.removePrompt(newPrompt);
+ else
+ needRemove = true;
+
+ PromptUtils.fireDialogEvent(window, "DOMModalDialogClosed", browser);
+ browser.messageManager.sendAsyncMessage("Prompt:Close", args);
+ }
+
+ browser.messageManager.addMessageListener("Prompt:ForceClose", function listener(message) {
+ // If this was for another prompt in the same tab, ignore it.
+ if (message.data._remoteId !== promptId) {
+ return;
+ }
+
+ browser.messageManager.removeMessageListener("Prompt:ForceClose", listener);
+
+ if (newPrompt) {
+ newPrompt.abortPrompt();
+ }
+ });
+
+ try {
+ let eventDetail = {
+ tabPrompt: true,
+ promptPrincipal: args.promptPrincipal,
+ inPermitUnload: args.inPermitUnload,
+ };
+ PromptUtils.fireDialogEvent(window, "DOMWillOpenModalDialog", browser, eventDetail);
+
+ args.promptActive = true;
+
+ newPrompt = tabPrompt.appendPrompt(args, onPromptClose);
+
+ if (needRemove) {
+ tabPrompt.removePrompt(newPrompt);
+ }
+
+ // TODO since we don't actually open a window, need to check if
+ // there's other stuff in nsWindowWatcher::OpenWindowInternal
+ // that we might need to do here as well.
+ } catch (ex) {
+ onPromptClose(true);
+ }
+ },
+
+ openModalWindow: function(args, browser) {
+ let window = browser.ownerGlobal;
+ try {
+ PromptUtils.fireDialogEvent(window, "DOMWillOpenModalDialog", browser);
+ let bag = PromptUtils.objectToPropBag(args);
+
+ Services.ww.openWindow(window, args.uri, "_blank",
+ "centerscreen,chrome,modal,titlebar", bag);
+
+ PromptUtils.propBagToObject(bag, args);
+ } finally {
+ PromptUtils.fireDialogEvent(window, "DOMModalDialogClosed", browser);
+ browser.messageManager.sendAsyncMessage("Prompt:Close", args);
+ }
+ }
+};
diff --git a/browser/modules/Sanitizer.jsm b/browser/modules/Sanitizer.jsm
new file mode 100644
index 000000000..31c2823c7
--- /dev/null
+++ b/browser/modules/Sanitizer.jsm
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+//
+// A shared module for sanitize.js
+//
+// Until bug 1167238 lands, this serves only as a way to ensure that
+// sanitize is loaded from its own compartment, rather than from that
+// of the sanitize dialog.
+//
+
+this.EXPORTED_SYMBOLS = ["Sanitizer"];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+var scope = {};
+Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader)
+ .loadSubScript("chrome://browser/content/sanitize.js", scope);
+
+this.Sanitizer = scope.Sanitizer;
diff --git a/browser/modules/SelfSupportBackend.jsm b/browser/modules/SelfSupportBackend.jsm
new file mode 100644
index 000000000..3a3f8cb8b
--- /dev/null
+++ b/browser/modules/SelfSupportBackend.jsm
@@ -0,0 +1,331 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["SelfSupportBackend"];
+
+const Cu = Components.utils;
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "HiddenFrame",
+ "resource:///modules/HiddenFrame.jsm");
+
+// Enables or disables the Self Support.
+const PREF_ENABLED = "browser.selfsupport.enabled";
+// Url to open in the Self Support browser, in the urlFormatter service format.
+const PREF_URL = "browser.selfsupport.url";
+// Unified Telemetry status.
+const PREF_TELEMETRY_UNIFIED = "toolkit.telemetry.unified";
+// UITour status.
+const PREF_UITOUR_ENABLED = "browser.uitour.enabled";
+
+// Controls the interval at which the self support page tries to reload in case of
+// errors.
+const RETRY_INTERVAL_MS = 30000;
+// Maximum number of SelfSupport page load attempts in case of failure.
+const MAX_RETRIES = 5;
+// The delay after which to load the self-support, at startup.
+const STARTUP_DELAY_MS = 5000;
+
+const LOGGER_NAME = "Browser.SelfSupportBackend";
+const PREF_BRANCH_LOG = "browser.selfsupport.log.";
+const PREF_LOG_LEVEL = PREF_BRANCH_LOG + "level";
+const PREF_LOG_DUMP = PREF_BRANCH_LOG + "dump";
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+const UITOUR_FRAME_SCRIPT = "chrome://browser/content/content-UITour.js";
+
+// Whether the FHR/Telemetry unification features are enabled.
+// Changing this pref requires a restart.
+const IS_UNIFIED_TELEMETRY = Preferences.get(PREF_TELEMETRY_UNIFIED, false);
+
+var gLogAppenderDump = null;
+
+this.SelfSupportBackend = Object.freeze({
+ init: function () {
+ SelfSupportBackendInternal.init();
+ },
+
+ uninit: function () {
+ SelfSupportBackendInternal.uninit();
+ },
+});
+
+var SelfSupportBackendInternal = {
+ // The browser element that will load the SelfSupport page.
+ _browser: null,
+ // The Id of the timer triggering delayed SelfSupport page load.
+ _delayedLoadTimerId: null,
+ // The HiddenFrame holding the _browser element.
+ _frame: null,
+ _log: null,
+ _progressListener: null,
+
+ /**
+ * Initializes the self support backend.
+ */
+ init: function () {
+ this._configureLogging();
+
+ this._log.trace("init");
+
+ Preferences.observe(PREF_BRANCH_LOG, this._configureLogging, this);
+
+ // Only allow to use SelfSupport if Unified Telemetry is enabled.
+ let reportingEnabled = IS_UNIFIED_TELEMETRY;
+ if (!reportingEnabled) {
+ this._log.config("init - Disabling SelfSupport because FHR and Unified Telemetry are disabled.");
+ return;
+ }
+
+ // Make sure UITour is enabled.
+ let uiTourEnabled = Preferences.get(PREF_UITOUR_ENABLED, false);
+ if (!uiTourEnabled) {
+ this._log.config("init - Disabling SelfSupport because UITour is disabled.");
+ return;
+ }
+
+ // Check the preferences to see if we want this to be active.
+ if (!Preferences.get(PREF_ENABLED, true)) {
+ this._log.config("init - SelfSupport is disabled.");
+ return;
+ }
+
+ Services.obs.addObserver(this, "sessionstore-windows-restored", false);
+ },
+
+ /**
+ * Shut down the self support backend, if active.
+ */
+ uninit: function () {
+ this._log.trace("uninit");
+
+ Preferences.ignore(PREF_BRANCH_LOG, this._configureLogging, this);
+
+ // Cancel delayed loading, if still active, when shutting down.
+ clearTimeout(this._delayedLoadTimerId);
+
+ // Dispose of the hidden browser.
+ if (this._browser !== null) {
+ if (this._browser.contentWindow) {
+ this._browser.contentWindow.removeEventListener("DOMWindowClose", this, true);
+ }
+
+ if (this._progressListener) {
+ this._browser.removeProgressListener(this._progressListener);
+ this._progressListener.destroy();
+ this._progressListener = null;
+ }
+
+ this._browser.remove();
+ this._browser = null;
+ }
+
+ if (this._frame) {
+ this._frame.destroy();
+ this._frame = null;
+ }
+ },
+
+ /**
+ * Handle notifications. Once all windows are created, we wait a little bit more
+ * since tabs might still be loading. Then, we open the self support.
+ */
+ observe: function (aSubject, aTopic, aData) {
+ this._log.trace("observe - Topic " + aTopic);
+
+ if (aTopic === "sessionstore-windows-restored") {
+ Services.obs.removeObserver(this, "sessionstore-windows-restored");
+ this._delayedLoadTimerId = setTimeout(this._loadSelfSupport.bind(this), STARTUP_DELAY_MS);
+ }
+ },
+
+ /**
+ * Configure the logger based on the preferences.
+ */
+ _configureLogging: function() {
+ if (!this._log) {
+ this._log = Log.repository.getLogger(LOGGER_NAME);
+
+ // Log messages need to go to the browser console.
+ let consoleAppender = new Log.ConsoleAppender(new Log.BasicFormatter());
+ this._log.addAppender(consoleAppender);
+ }
+
+ // Make sure the logger keeps up with the logging level preference.
+ this._log.level = Log.Level[Preferences.get(PREF_LOG_LEVEL, "Warn")];
+
+ // If enabled in the preferences, add a dump appender.
+ let logDumping = Preferences.get(PREF_LOG_DUMP, false);
+ if (logDumping != !!gLogAppenderDump) {
+ if (logDumping) {
+ gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter());
+ this._log.addAppender(gLogAppenderDump);
+ } else {
+ this._log.removeAppender(gLogAppenderDump);
+ gLogAppenderDump = null;
+ }
+ }
+ },
+
+ /**
+ * Create an hidden frame to host our |browser|, then load the SelfSupport page in it.
+ * @param aURL The URL to load in the browser.
+ */
+ _makeHiddenBrowser: function(aURL) {
+ this._frame = new HiddenFrame();
+ return this._frame.get().then(aFrame => {
+ let doc = aFrame.document;
+
+ this._browser = doc.createElementNS(XUL_NS, "browser");
+ this._browser.setAttribute("type", "content");
+ this._browser.setAttribute("disableglobalhistory", "true");
+ this._browser.setAttribute("src", aURL);
+
+ doc.documentElement.appendChild(this._browser);
+ });
+ },
+
+ handleEvent: function(aEvent) {
+ this._log.trace("handleEvent - aEvent.type " + aEvent.type + ", Trusted " + aEvent.isTrusted);
+
+ if (aEvent.type === "DOMWindowClose") {
+ let window = this._browser.contentDocument.defaultView;
+ let target = aEvent.target;
+
+ if (target == window) {
+ // preventDefault stops the default window.close(). We need to do that to prevent
+ // Services.appShell.hiddenDOMWindow from being destroyed.
+ aEvent.preventDefault();
+
+ this.uninit();
+ }
+ }
+ },
+
+ /**
+ * Called when the self support page correctly loads.
+ */
+ _pageSuccessCallback: function() {
+ this._log.debug("_pageSuccessCallback - Page correctly loaded.");
+ this._browser.removeProgressListener(this._progressListener);
+ this._progressListener.destroy();
+ this._progressListener = null;
+
+ // Allow SelfSupportBackend to catch |window.close()| issued by the content.
+ this._browser.contentWindow.addEventListener("DOMWindowClose", this, true);
+ },
+
+ /**
+ * Called when the self support page fails to load.
+ */
+ _pageLoadErrorCallback: function() {
+ this._log.info("_pageLoadErrorCallback - Too many failed load attempts. Giving up.");
+ this.uninit();
+ },
+
+ /**
+ * Create a browser and attach it to an hidden window. The browser will contain the
+ * self support page and attempt to load the page content. If loading fails, try again
+ * after an interval.
+ */
+ _loadSelfSupport: function() {
+ // Fetch the Self Support URL from the preferences.
+ let unformattedURL = Preferences.get(PREF_URL, null);
+ let url = Services.urlFormatter.formatURL(unformattedURL);
+ if (!url.startsWith("https:")) {
+ this._log.error("_loadSelfSupport - Non HTTPS URL provided: " + url);
+ return;
+ }
+
+ this._log.config("_loadSelfSupport - URL " + url);
+
+ // Create the hidden browser.
+ this._makeHiddenBrowser(url).then(() => {
+ // Load UITour frame script.
+ this._browser.messageManager.loadFrameScript(UITOUR_FRAME_SCRIPT, true);
+
+ // We need to watch for load errors as well and, in case, try to reload
+ // the self support page.
+ const webFlags = Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
+ Ci.nsIWebProgress.NOTIFY_STATE_REQUEST |
+ Ci.nsIWebProgress.NOTIFY_LOCATION;
+
+ this._progressListener = new ProgressListener(() => this._pageLoadErrorCallback(),
+ () => this._pageSuccessCallback());
+
+ this._browser.addProgressListener(this._progressListener, webFlags);
+ });
+ }
+};
+
+/**
+ * A progress listener object which notifies of page load error and load success
+ * through callbacks. When the page fails to load, the progress listener tries to
+ * reload it up to MAX_RETRIES times. The page is not loaded again immediately, but
+ * after a timeout.
+ *
+ * @param aLoadErrorCallback Called when a page failed to load MAX_RETRIES times.
+ * @param aLoadSuccessCallback Called when a page correctly loads.
+ */
+function ProgressListener(aLoadErrorCallback, aLoadSuccessCallback) {
+ this._loadErrorCallback = aLoadErrorCallback;
+ this._loadSuccessCallback = aLoadSuccessCallback;
+ // The number of page loads attempted.
+ this._loadAttempts = 0;
+ this._log = Log.repository.getLogger(LOGGER_NAME);
+ // The Id of the timer which triggers page load again in case of errors.
+ this._reloadTimerId = null;
+}
+
+ProgressListener.prototype = {
+ onLocationChange: function(aWebProgress, aRequest, aLocation, aFlags) {
+ if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
+ this._log.warn("onLocationChange - There was a problem fetching the SelfSupport URL (attempt " +
+ this._loadAttempts + ").");
+
+ // Increase the number of attempts and bail out if we failed too many times.
+ this._loadAttempts++;
+ if (this._loadAttempts > MAX_RETRIES) {
+ this._loadErrorCallback();
+ return;
+ }
+
+ // Reload the page after the retry interval expires. The interval is multiplied
+ // by the number of attempted loads, so that it takes a bit more to try to reload
+ // when frequently failing.
+ this._reloadTimerId = setTimeout(() => {
+ this._log.debug("onLocationChange - Reloading SelfSupport URL in the hidden browser.");
+ aWebProgress.DOMWindow.location.reload();
+ }, RETRY_INTERVAL_MS * this._loadAttempts);
+ }
+ },
+
+ onStateChange: function (aWebProgress, aRequest, aFlags, aStatus) {
+ if (aFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ aFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK &&
+ aFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW &&
+ Components.isSuccessCode(aStatus)) {
+ this._loadSuccessCallback();
+ }
+ },
+
+ destroy: function () {
+ // Make sure we don't try to reload self support when shutting down.
+ clearTimeout(this._reloadTimerId);
+ },
+
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener,
+ Ci.nsISupportsWeakReference]),
+};
diff --git a/browser/modules/SitePermissions.jsm b/browser/modules/SitePermissions.jsm
new file mode 100644
index 000000000..d15ddb21b
--- /dev/null
+++ b/browser/modules/SitePermissions.jsm
@@ -0,0 +1,269 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.EXPORTED_SYMBOLS = [ "SitePermissions" ];
+
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+var gStringBundle =
+ Services.strings.createBundle("chrome://browser/locale/sitePermissions.properties");
+
+this.SitePermissions = {
+
+ UNKNOWN: Services.perms.UNKNOWN_ACTION,
+ ALLOW: Services.perms.ALLOW_ACTION,
+ BLOCK: Services.perms.DENY_ACTION,
+ SESSION: Components.interfaces.nsICookiePermission.ACCESS_SESSION,
+
+ /* Returns all custom permissions for a given URI, the return
+ * type is a list of objects with the keys:
+ * - id: the permissionId of the permission
+ * - state: a constant representing the current permission state
+ * (e.g. SitePermissions.ALLOW)
+ *
+ * To receive a more detailed, albeit less performant listing see
+ * SitePermissions.getPermissionDetailsByURI().
+ *
+ * install addon permission is excluded, check bug 1303108
+ */
+ getAllByURI: function (aURI) {
+ let result = [];
+ if (!this.isSupportedURI(aURI)) {
+ return result;
+ }
+
+ let permissions = Services.perms.getAllForURI(aURI);
+ while (permissions.hasMoreElements()) {
+ let permission = permissions.getNext();
+
+ // filter out unknown permissions
+ if (gPermissionObject[permission.type]) {
+ // XXX Bug 1303108 - Control Center should only show non-default permissions
+ if (permission.type == "install") {
+ continue;
+ }
+ result.push({
+ id: permission.type,
+ state: permission.capability,
+ });
+ }
+ }
+
+ return result;
+ },
+
+ /* Returns an object representing the aId permission. It contains the
+ * following keys:
+ * - id: the permissionID of the permission
+ * - label: the localized label
+ * - state: a constant representing the aState permission state
+ * (e.g. SitePermissions.ALLOW), or the default if aState is omitted
+ * - availableStates: an array of all available states for that permission,
+ * represented as objects with the keys:
+ * - id: the state constant
+ * - label: the translated label of that state
+ */
+ getPermissionItem: function (aId, aState) {
+ let availableStates = this.getAvailableStates(aId).map(state => {
+ return { id: state, label: this.getStateLabel(aId, state) };
+ });
+ if (aState == undefined)
+ aState = this.getDefault(aId);
+ return {id: aId, label: this.getPermissionLabel(aId),
+ state: aState, availableStates};
+ },
+
+ /* Returns a list of objects representing all permissions that are currently
+ * set for the given URI. See getPermissionItem for the content of each object.
+ */
+ getPermissionDetailsByURI: function (aURI) {
+ let permissions = [];
+ for (let {state, id} of this.getAllByURI(aURI)) {
+ permissions.push(this.getPermissionItem(id, state));
+ }
+
+ return permissions;
+ },
+
+ /* Checks whether a UI for managing permissions should be exposed for a given
+ * URI. This excludes file URIs, for instance, as they don't have a host,
+ * even though nsIPermissionManager can still handle them.
+ */
+ isSupportedURI: function (aURI) {
+ return aURI.schemeIs("http") || aURI.schemeIs("https");
+ },
+
+ /* Returns an array of all permission IDs.
+ */
+ listPermissions: function () {
+ return Object.keys(gPermissionObject);
+ },
+
+ /* Returns an array of permission states to be exposed to the user for a
+ * permission with the given ID.
+ */
+ getAvailableStates: function (aPermissionID) {
+ if (aPermissionID in gPermissionObject &&
+ gPermissionObject[aPermissionID].states)
+ return gPermissionObject[aPermissionID].states;
+
+ if (this.getDefault(aPermissionID) == this.UNKNOWN)
+ return [ SitePermissions.UNKNOWN, SitePermissions.ALLOW, SitePermissions.BLOCK ];
+
+ return [ SitePermissions.ALLOW, SitePermissions.BLOCK ];
+ },
+
+ /* Returns the default state of a particular permission.
+ */
+ getDefault: function (aPermissionID) {
+ if (aPermissionID in gPermissionObject &&
+ gPermissionObject[aPermissionID].getDefault)
+ return gPermissionObject[aPermissionID].getDefault();
+
+ return this.UNKNOWN;
+ },
+
+ /* Returns the state of a particular permission for a given URI.
+ */
+ get: function (aURI, aPermissionID) {
+ if (!this.isSupportedURI(aURI))
+ return this.UNKNOWN;
+
+ let state;
+ if (aPermissionID in gPermissionObject &&
+ gPermissionObject[aPermissionID].exactHostMatch)
+ state = Services.perms.testExactPermission(aURI, aPermissionID);
+ else
+ state = Services.perms.testPermission(aURI, aPermissionID);
+ return state;
+ },
+
+ /* Sets the state of a particular permission for a given URI.
+ */
+ set: function (aURI, aPermissionID, aState) {
+ if (!this.isSupportedURI(aURI))
+ return;
+
+ if (aState == this.UNKNOWN) {
+ this.remove(aURI, aPermissionID);
+ return;
+ }
+
+ Services.perms.add(aURI, aPermissionID, aState);
+ },
+
+ /* Removes the saved state of a particular permission for a given URI.
+ */
+ remove: function (aURI, aPermissionID) {
+ if (!this.isSupportedURI(aURI))
+ return;
+
+ Services.perms.remove(aURI, aPermissionID);
+ },
+
+ /* Returns the localized label for the permission with the given ID, to be
+ * used in a UI for managing permissions.
+ */
+ getPermissionLabel: function (aPermissionID) {
+ let labelID = gPermissionObject[aPermissionID].labelID || aPermissionID;
+ return gStringBundle.GetStringFromName("permission." + labelID + ".label");
+ },
+
+ /* Returns the localized label for the given permission state, to be used in
+ * a UI for managing permissions.
+ */
+ getStateLabel: function (aPermissionID, aState, aInUse = false) {
+ switch (aState) {
+ case this.UNKNOWN:
+ if (aInUse)
+ return gStringBundle.GetStringFromName("allowTemporarily");
+ return gStringBundle.GetStringFromName("alwaysAsk");
+ case this.ALLOW:
+ return gStringBundle.GetStringFromName("allow");
+ case this.SESSION:
+ return gStringBundle.GetStringFromName("allowForSession");
+ case this.BLOCK:
+ return gStringBundle.GetStringFromName("block");
+ default:
+ return null;
+ }
+ }
+};
+
+var gPermissionObject = {
+ /* Holds permission ID => options pairs.
+ *
+ * Supported options:
+ *
+ * - exactHostMatch
+ * Allows sub domains to have their own permissions.
+ * Defaults to false.
+ *
+ * - getDefault
+ * Called to get the permission's default state.
+ * Defaults to UNKNOWN, indicating that the user will be asked each time
+ * a page asks for that permissions.
+ *
+ * - labelID
+ * Use the given ID instead of the permission name for looking up strings.
+ * e.g. "desktop-notification2" to use permission.desktop-notification2.label
+ *
+ * - states
+ * Array of permission states to be exposed to the user.
+ * Defaults to ALLOW, BLOCK and the default state (see getDefault).
+ */
+
+ "image": {
+ getDefault: function () {
+ return Services.prefs.getIntPref("permissions.default.image") == 2 ?
+ SitePermissions.BLOCK : SitePermissions.ALLOW;
+ }
+ },
+
+ "cookie": {
+ states: [ SitePermissions.ALLOW, SitePermissions.SESSION, SitePermissions.BLOCK ],
+ getDefault: function () {
+ if (Services.prefs.getIntPref("network.cookie.cookieBehavior") == 2)
+ return SitePermissions.BLOCK;
+
+ if (Services.prefs.getIntPref("network.cookie.lifetimePolicy") == 2)
+ return SitePermissions.SESSION;
+
+ return SitePermissions.ALLOW;
+ }
+ },
+
+ "desktop-notification": {
+ exactHostMatch: true,
+ labelID: "desktop-notification2",
+ },
+
+ "camera": {},
+ "microphone": {},
+ "screen": {
+ states: [ SitePermissions.UNKNOWN, SitePermissions.BLOCK ],
+ },
+
+ "popup": {
+ getDefault: function () {
+ return Services.prefs.getBoolPref("dom.disable_open_during_load") ?
+ SitePermissions.BLOCK : SitePermissions.ALLOW;
+ }
+ },
+
+ "install": {
+ getDefault: function () {
+ return Services.prefs.getBoolPref("xpinstall.whitelist.required") ?
+ SitePermissions.BLOCK : SitePermissions.ALLOW;
+ }
+ },
+
+ "geo": {
+ exactHostMatch: true
+ },
+
+ "indexedDB": {}
+};
+
+const kPermissionIDs = Object.keys(gPermissionObject);
diff --git a/browser/modules/Social.jsm b/browser/modules/Social.jsm
new file mode 100644
index 000000000..1569e0122
--- /dev/null
+++ b/browser/modules/Social.jsm
@@ -0,0 +1,272 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["Social", "OpenGraphBuilder",
+ "DynamicResizeWatcher", "sizeSocialPanelToContent"];
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+
+// The minimum sizes for the auto-resize panel code, minimum size necessary to
+// properly show the error page in the panel.
+const PANEL_MIN_HEIGHT = 190;
+const PANEL_MIN_WIDTH = 330;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
+ "resource:///modules/CustomizableUI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "SocialService",
+ "resource:///modules/SocialService.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PageMetadata",
+ "resource://gre/modules/PageMetadata.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+
+
+this.Social = {
+ initialized: false,
+ lastEventReceived: 0,
+ providers: [],
+ _disabledForSafeMode: false,
+
+ init: function Social_init() {
+ this._disabledForSafeMode = Services.appinfo.inSafeMode && this.enabled;
+ let deferred = Promise.defer();
+
+ if (this.initialized) {
+ deferred.resolve(true);
+ return deferred.promise;
+ }
+ this.initialized = true;
+ // if SocialService.hasEnabledProviders, retreive the providers so the
+ // front-end can generate UI
+ if (SocialService.hasEnabledProviders) {
+ // Retrieve the current set of providers, and set the current provider.
+ SocialService.getOrderedProviderList(function (providers) {
+ Social._updateProviderCache(providers);
+ Social._updateEnabledState(SocialService.enabled);
+ deferred.resolve(false);
+ });
+ } else {
+ deferred.resolve(false);
+ }
+
+ // Register an observer for changes to the provider list
+ SocialService.registerProviderListener(function providerListener(topic, origin, providers) {
+ // An engine change caused by adding/removing a provider should notify.
+ // any providers we receive are enabled in the AddonsManager
+ if (topic == "provider-installed" || topic == "provider-uninstalled") {
+ // installed/uninstalled do not send the providers param
+ Services.obs.notifyObservers(null, "social:" + topic, origin);
+ return;
+ }
+ if (topic == "provider-enabled") {
+ Social._updateProviderCache(providers);
+ Social._updateEnabledState(true);
+ Services.obs.notifyObservers(null, "social:" + topic, origin);
+ return;
+ }
+ if (topic == "provider-disabled") {
+ // a provider was removed from the list of providers, update states
+ Social._updateProviderCache(providers);
+ Social._updateEnabledState(providers.length > 0);
+ Services.obs.notifyObservers(null, "social:" + topic, origin);
+ return;
+ }
+ if (topic == "provider-update") {
+ // a provider has self-updated its manifest, we need to update our cache
+ // and reload the provider.
+ Social._updateProviderCache(providers);
+ let provider = Social._getProviderFromOrigin(origin);
+ provider.reload();
+ }
+ });
+ return deferred.promise;
+ },
+
+ _updateEnabledState: function(enable) {
+ for (let p of Social.providers) {
+ p.enabled = enable;
+ }
+ },
+
+ // Called to update our cache of providers and set the current provider
+ _updateProviderCache: function (providers) {
+ this.providers = providers;
+ Services.obs.notifyObservers(null, "social:providers-changed", null);
+ },
+
+ get enabled() {
+ return !this._disabledForSafeMode && this.providers.length > 0;
+ },
+
+ _getProviderFromOrigin: function (origin) {
+ for (let p of this.providers) {
+ if (p.origin == origin) {
+ return p;
+ }
+ }
+ return null;
+ },
+
+ getManifestByOrigin: function(origin) {
+ return SocialService.getManifestByOrigin(origin);
+ },
+
+ installProvider: function(data, installCallback, options={}) {
+ SocialService.installProvider(data, installCallback, options);
+ },
+
+ uninstallProvider: function(origin, aCallback) {
+ SocialService.uninstallProvider(origin, aCallback);
+ },
+
+ // Activation functionality
+ activateFromOrigin: function (origin, callback) {
+ // It's OK if the provider has already been activated - we still get called
+ // back with it.
+ SocialService.enableProvider(origin, callback);
+ }
+};
+
+function sizeSocialPanelToContent(panel, iframe, requestedSize) {
+ let doc = iframe.contentDocument;
+ if (!doc || !doc.body) {
+ return;
+ }
+ // We need an element to use for sizing our panel. See if the body defines
+ // an id for that element, otherwise use the body itself.
+ let body = doc.body;
+ let docEl = doc.documentElement;
+ let bodyId = body.getAttribute("contentid");
+ if (bodyId) {
+ body = doc.getElementById(bodyId) || doc.body;
+ }
+ // offsetHeight/Width don't include margins, so account for that.
+ let cs = doc.defaultView.getComputedStyle(body);
+ let width = Math.max(PANEL_MIN_WIDTH, docEl.offsetWidth);
+ let height = Math.max(PANEL_MIN_HEIGHT, docEl.offsetHeight);
+ // if the panel is preloaded prior to being shown, cs will be null. in that
+ // case use the minimum size for the panel until it is shown.
+ if (cs) {
+ let computedHeight = parseInt(cs.marginTop) + body.offsetHeight + parseInt(cs.marginBottom);
+ height = Math.max(computedHeight, height);
+ let computedWidth = parseInt(cs.marginLeft) + body.offsetWidth + parseInt(cs.marginRight);
+ width = Math.max(computedWidth, width);
+ }
+
+ // if our scrollHeight is still larger than the iframe, the css calculations
+ // above did not work for this site, increase the height. This can happen if
+ // the site increases its height for additional UI.
+ if (docEl.scrollHeight > iframe.boxObject.height)
+ height = docEl.scrollHeight;
+
+ // if a size was defined in the manifest use it as a minimum
+ if (requestedSize) {
+ if (requestedSize.height)
+ height = Math.max(height, requestedSize.height);
+ if (requestedSize.width)
+ width = Math.max(width, requestedSize.width);
+ }
+
+ // add the extra space used by the panel (toolbar, borders, etc) if the iframe
+ // has been loaded
+ if (iframe.boxObject.width && iframe.boxObject.height) {
+ // add extra space the panel needs if any
+ width += panel.boxObject.width - iframe.boxObject.width;
+ height += panel.boxObject.height - iframe.boxObject.height;
+ }
+
+ // using panel.sizeTo will ignore css transitions, set size via style
+ if (Math.abs(panel.boxObject.width - width) >= 2)
+ panel.style.width = width + "px";
+ if (Math.abs(panel.boxObject.height - height) >= 2)
+ panel.style.height = height + "px";
+}
+
+function DynamicResizeWatcher() {
+ this._mutationObserver = null;
+}
+
+DynamicResizeWatcher.prototype = {
+ start: function DynamicResizeWatcher_start(panel, iframe, requestedSize) {
+ this.stop(); // just in case...
+ let doc = iframe.contentDocument;
+ this._mutationObserver = new iframe.contentWindow.MutationObserver((mutations) => {
+ sizeSocialPanelToContent(panel, iframe, requestedSize);
+ });
+ // Observe anything that causes the size to change.
+ let config = {attributes: true, characterData: true, childList: true, subtree: true};
+ this._mutationObserver.observe(doc, config);
+ // and since this may be setup after the load event has fired we do an
+ // initial resize now.
+ sizeSocialPanelToContent(panel, iframe, requestedSize);
+ },
+ stop: function DynamicResizeWatcher_stop() {
+ if (this._mutationObserver) {
+ try {
+ this._mutationObserver.disconnect();
+ } catch (ex) {
+ // may get "TypeError: can't access dead object" which seems strange,
+ // but doesn't seem to indicate a real problem, so ignore it...
+ }
+ this._mutationObserver = null;
+ }
+ }
+}
+
+
+this.OpenGraphBuilder = {
+ generateEndpointURL: function(URLTemplate, pageData) {
+ // support for existing oexchange style endpoints by supporting their
+ // querystring arguments. parse the query string template and do
+ // replacements where necessary the query names may be different than ours,
+ // so we could see u=%{url} or url=%{url}
+ let [endpointURL, queryString] = URLTemplate.split("?");
+ let query = {};
+ if (queryString) {
+ queryString.split('&').forEach(function (val) {
+ let [name, value] = val.split('=');
+ let p = /%\{(.+)\}/.exec(value);
+ if (!p) {
+ // preserve non-template query vars
+ query[name] = value;
+ } else if (pageData[p[1]]) {
+ if (p[1] == "previews")
+ query[name] = pageData[p[1]][0];
+ else
+ query[name] = pageData[p[1]];
+ } else if (p[1] == "body") {
+ // build a body for emailers
+ let body = "";
+ if (pageData.title)
+ body += pageData.title + "\n\n";
+ if (pageData.description)
+ body += pageData.description + "\n\n";
+ if (pageData.text)
+ body += pageData.text + "\n\n";
+ body += pageData.url;
+ query["body"] = body;
+ }
+ });
+ // if the url template doesn't have title and no text was provided, add the title as the text.
+ if (!query.text && !query.title && pageData.title) {
+ query.text = pageData.title;
+ }
+ }
+ var str = [];
+ for (let p in query)
+ str.push(p + "=" + encodeURIComponent(query[p]));
+ if (str.length)
+ endpointURL = endpointURL + "?" + str.join("&");
+ return endpointURL;
+ },
+};
diff --git a/browser/modules/SocialService.jsm b/browser/modules/SocialService.jsm
new file mode 100644
index 000000000..95f5e0259
--- /dev/null
+++ b/browser/modules/SocialService.jsm
@@ -0,0 +1,1097 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.EXPORTED_SYMBOLS = ["SocialService"];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/AddonManager.jsm");
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+
+const URI_EXTENSION_STRINGS = "chrome://mozapps/locale/extensions/extensions.properties";
+const ADDON_TYPE_SERVICE = "service";
+const ID_SUFFIX = "@services.mozilla.org";
+const STRING_TYPE_NAME = "type.%ID%.name";
+
+XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask", "resource://gre/modules/DeferredTask.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "etld",
+ "@mozilla.org/network/effective-tld-service;1",
+ "nsIEffectiveTLDService");
+
+/**
+ * The SocialService is the public API to social providers - it tracks which
+ * providers are installed and enabled, and is the entry-point for access to
+ * the provider itself.
+ */
+
+// Internal helper methods and state
+var SocialServiceInternal = {
+ get enabled() {
+ return this.providerArray.length > 0;
+ },
+
+ get providerArray() {
+ return Object.keys(this.providers).map(origin => this.providers[origin]);
+ },
+ *manifestsGenerator() {
+ // Retrieve the manifests of installed providers from prefs
+ let MANIFEST_PREFS = Services.prefs.getBranch("social.manifest.");
+ let prefs = MANIFEST_PREFS.getChildList("", []);
+ for (let pref of prefs) {
+ // we only consider manifests in user level prefs to be *installed*
+ if (!MANIFEST_PREFS.prefHasUserValue(pref))
+ continue;
+ try {
+ var manifest = JSON.parse(MANIFEST_PREFS.getComplexValue(pref, Ci.nsISupportsString).data);
+ if (manifest && typeof(manifest) == "object" && manifest.origin)
+ yield manifest;
+ } catch (err) {
+ Cu.reportError("SocialService: failed to load manifest: " + pref +
+ ", exception: " + err);
+ }
+ }
+ },
+ get manifests() {
+ return this.manifestsGenerator();
+ },
+ getManifestPrefname: function(origin) {
+ // Retrieve the prefname for a given origin/manifest.
+ // If no existing pref, return a generated prefname.
+ let MANIFEST_PREFS = Services.prefs.getBranch("social.manifest.");
+ let prefs = MANIFEST_PREFS.getChildList("", []);
+ for (let pref of prefs) {
+ try {
+ var manifest = JSON.parse(MANIFEST_PREFS.getComplexValue(pref, Ci.nsISupportsString).data);
+ if (manifest.origin == origin) {
+ return pref;
+ }
+ } catch (err) {
+ Cu.reportError("SocialService: failed to load manifest: " + pref +
+ ", exception: " + err);
+ }
+ }
+ let originUri = Services.io.newURI(origin, null, null);
+ return originUri.hostPort.replace('.', '-');
+ },
+ orderedProviders: function(aCallback) {
+ if (SocialServiceInternal.providerArray.length < 2) {
+ schedule(function () {
+ aCallback(SocialServiceInternal.providerArray);
+ });
+ return;
+ }
+ // query moz_hosts for frecency. since some providers may not have a
+ // frecency entry, we need to later sort on our own. We use the providers
+ // object below as an easy way to later record the frecency on the provider
+ // object from the query results.
+ let hosts = [];
+ let providers = {};
+
+ for (let p of SocialServiceInternal.providerArray) {
+ p.frecency = 0;
+ providers[p.domain] = p;
+ hosts.push(p.domain);
+ }
+
+ // cannot bind an array to stmt.params so we have to build the string
+ let stmt = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection.createAsyncStatement(
+ "SELECT host, frecency FROM moz_hosts WHERE host IN (" +
+ hosts.map(host => '"' + host + '"').join(",") + ") "
+ );
+
+ try {
+ stmt.executeAsync({
+ handleResult: function(aResultSet) {
+ let row;
+ while ((row = aResultSet.getNextRow())) {
+ let rh = row.getResultByName("host");
+ let frecency = row.getResultByName("frecency");
+ providers[rh].frecency = parseInt(frecency) || 0;
+ }
+ },
+ handleError: function(aError) {
+ Cu.reportError(aError.message + " (Result = " + aError.result + ")");
+ },
+ handleCompletion: function(aReason) {
+ // the query may not have returned all our providers, so we have
+ // stamped the frecency on the provider and sort here. This makes sure
+ // all enabled providers get sorted even with frecency zero.
+ let providerList = SocialServiceInternal.providerArray;
+ // reverse sort
+ aCallback(providerList.sort((a, b) => b.frecency - a.frecency));
+ }
+ });
+ } finally {
+ stmt.finalize();
+ }
+ }
+};
+
+XPCOMUtils.defineLazyGetter(SocialServiceInternal, "providers", function () {
+ initService();
+ let providers = {};
+ for (let manifest of this.manifests) {
+ try {
+ if (ActiveProviders.has(manifest.origin)) {
+ // enable the api when a provider is enabled
+ let provider = new SocialProvider(manifest);
+ providers[provider.origin] = provider;
+ }
+ } catch (err) {
+ Cu.reportError("SocialService: failed to load provider: " + manifest.origin +
+ ", exception: " + err);
+ }
+ }
+ return providers;
+});
+
+function getOriginActivationType(origin) {
+ // if this is an about uri, treat it as a directory
+ let URI = Services.io.newURI(origin, null, null);
+ let principal = Services.scriptSecurityManager.createCodebasePrincipal(URI, {});
+ if (Services.scriptSecurityManager.isSystemPrincipal(principal) || origin == "moz-safe-about:home") {
+ return "internal";
+ }
+
+ let directories = Services.prefs.getCharPref("social.directories").split(',');
+ if (directories.indexOf(origin) >= 0)
+ return "directory";
+
+ return "foreign";
+}
+
+var ActiveProviders = {
+ get _providers() {
+ delete this._providers;
+ this._providers = {};
+ try {
+ let pref = Services.prefs.getComplexValue("social.activeProviders",
+ Ci.nsISupportsString);
+ this._providers = JSON.parse(pref);
+ } catch (ex) {}
+ return this._providers;
+ },
+
+ has: function (origin) {
+ return (origin in this._providers);
+ },
+
+ add: function (origin) {
+ this._providers[origin] = 1;
+ this._deferredTask.arm();
+ },
+
+ delete: function (origin) {
+ delete this._providers[origin];
+ this._deferredTask.arm();
+ },
+
+ flush: function () {
+ this._deferredTask.disarm();
+ this._persist();
+ },
+
+ get _deferredTask() {
+ delete this._deferredTask;
+ return this._deferredTask = new DeferredTask(this._persist.bind(this), 0);
+ },
+
+ _persist: function () {
+ let string = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ string.data = JSON.stringify(this._providers);
+ Services.prefs.setComplexValue("social.activeProviders",
+ Ci.nsISupportsString, string);
+ }
+};
+
+function migrateSettings() {
+ let activeProviders, enabled;
+ try {
+ activeProviders = Services.prefs.getCharPref("social.activeProviders");
+ } catch (e) {
+ // not set, we'll check if we need to migrate older prefs
+ }
+ if (Services.prefs.prefHasUserValue("social.enabled")) {
+ enabled = Services.prefs.getBoolPref("social.enabled");
+ }
+ if (activeProviders) {
+ // migration from fx21 to fx22 or later
+ // ensure any *builtin* provider in activeproviders is in user level prefs
+ for (let origin in ActiveProviders._providers) {
+ let prefname;
+ let manifest;
+ let defaultManifest;
+ try {
+ prefname = getPrefnameFromOrigin(origin);
+ manifest = JSON.parse(Services.prefs.getComplexValue(prefname, Ci.nsISupportsString).data);
+ } catch (e) {
+ // Our preference is missing or bad, remove from ActiveProviders and
+ // continue. This is primarily an error-case and should only be
+ // reached by either messing with preferences or hitting the one or
+ // two days of nightly that ran into it, so we'll flush right away.
+ ActiveProviders.delete(origin);
+ ActiveProviders.flush();
+ continue;
+ }
+ let needsUpdate = !manifest.updateDate;
+ // fx23 may have built-ins with shareURL
+ try {
+ defaultManifest = Services.prefs.getDefaultBranch(null)
+ .getComplexValue(prefname, Ci.nsISupportsString).data;
+ defaultManifest = JSON.parse(defaultManifest);
+ } catch (e) {
+ // not a built-in, continue
+ }
+ if (defaultManifest) {
+ if (defaultManifest.shareURL && !manifest.shareURL) {
+ manifest.shareURL = defaultManifest.shareURL;
+ needsUpdate = true;
+ }
+ if (defaultManifest.version && (!manifest.version || defaultManifest.version > manifest.version)) {
+ manifest = defaultManifest;
+ needsUpdate = true;
+ }
+ }
+ if (needsUpdate) {
+ // the provider was installed with an older build, so we will update the
+ // timestamp and ensure the manifest is in user prefs
+ delete manifest.builtin;
+ // we're potentially updating for share, so always mark the updateDate
+ manifest.updateDate = Date.now();
+ if (!manifest.installDate)
+ manifest.installDate = 0; // we don't know when it was installed
+
+ let string = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ string.data = JSON.stringify(manifest);
+ Services.prefs.setComplexValue(prefname, Ci.nsISupportsString, string);
+ }
+ // as of fx 29, we no longer rely on social.enabled. migration from prior
+ // versions should disable all service addons if social.enabled=false
+ if (enabled === false) {
+ ActiveProviders.delete(origin);
+ }
+ }
+ ActiveProviders.flush();
+ Services.prefs.clearUserPref("social.enabled");
+ return;
+ }
+
+ // primary migration from pre-fx21
+ let active;
+ try {
+ active = Services.prefs.getBoolPref("social.active");
+ } catch (e) {}
+ if (!active)
+ return;
+
+ // primary difference from SocialServiceInternal.manifests is that we
+ // only read the default branch here.
+ let manifestPrefs = Services.prefs.getDefaultBranch("social.manifest.");
+ let prefs = manifestPrefs.getChildList("", []);
+ for (let pref of prefs) {
+ try {
+ let manifest;
+ try {
+ manifest = JSON.parse(manifestPrefs.getComplexValue(pref, Ci.nsISupportsString).data);
+ } catch (e) {
+ // bad or missing preference, we wont update this one.
+ continue;
+ }
+ if (manifest && typeof(manifest) == "object" && manifest.origin) {
+ // our default manifests have been updated with the builtin flags as of
+ // fx22, delete it so we can set the user-pref
+ delete manifest.builtin;
+ if (!manifest.updateDate) {
+ manifest.updateDate = Date.now();
+ manifest.installDate = 0; // we don't know when it was installed
+ }
+
+ let string = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
+ string.data = JSON.stringify(manifest);
+ // pref here is just the branch name, set the full pref name
+ Services.prefs.setComplexValue("social.manifest." + pref, Ci.nsISupportsString, string);
+ ActiveProviders.add(manifest.origin);
+ ActiveProviders.flush();
+ // social.active was used at a time that there was only one
+ // builtin, we'll assume that is still the case
+ return;
+ }
+ } catch (err) {
+ Cu.reportError("SocialService: failed to load manifest: " + pref + ", exception: " + err);
+ }
+ }
+}
+
+function initService() {
+ Services.obs.addObserver(function xpcomShutdown() {
+ ActiveProviders.flush();
+ SocialService._providerListeners = null;
+ Services.obs.removeObserver(xpcomShutdown, "xpcom-shutdown");
+ }, "xpcom-shutdown", false);
+
+ try {
+ migrateSettings();
+ } catch (e) {
+ // no matter what, if migration fails we do not want to render social
+ // unusable. Worst case scenario is that, when upgrading Firefox, previously
+ // enabled providers are not migrated.
+ Cu.reportError("Error migrating social settings: " + e);
+ }
+}
+
+function schedule(callback) {
+ Services.tm.mainThread.dispatch(callback, Ci.nsIThread.DISPATCH_NORMAL);
+}
+
+// Public API
+this.SocialService = {
+ get hasEnabledProviders() {
+ // used as an optimization during startup, can be used to check if further
+ // initialization should be done (e.g. creating the instances of
+ // SocialProvider and turning on UI). ActiveProviders may have changed and
+ // not yet flushed so we check the active providers array
+ for (let p in ActiveProviders._providers) {
+ return true;
+ }
+ return false;
+ },
+ get enabled() {
+ return SocialServiceInternal.enabled;
+ },
+ set enabled(val) {
+ throw new Error("not allowed to set SocialService.enabled");
+ },
+
+ // Enables a provider, the manifest must already exist in prefs. The provider
+ // may or may not have previously been added. onDone is always called
+ // - with null if no such provider exists, or the activated provider on
+ // success.
+ enableProvider: function enableProvider(origin, onDone) {
+ if (SocialServiceInternal.providers[origin]) {
+ schedule(function() {
+ onDone(SocialServiceInternal.providers[origin]);
+ });
+ return;
+ }
+ let manifest = SocialService.getManifestByOrigin(origin);
+ if (manifest) {
+ let addon = new AddonWrapper(manifest);
+ AddonManagerPrivate.callAddonListeners("onEnabling", addon, false);
+ addon.pendingOperations |= AddonManager.PENDING_ENABLE;
+ this.addProvider(manifest, onDone);
+ addon.pendingOperations -= AddonManager.PENDING_ENABLE;
+ AddonManagerPrivate.callAddonListeners("onEnabled", addon);
+ return;
+ }
+ schedule(function() {
+ onDone(null);
+ });
+ },
+
+ // Adds a provider given a manifest, and returns the added provider.
+ addProvider: function addProvider(manifest, onDone) {
+ if (SocialServiceInternal.providers[manifest.origin])
+ throw new Error("SocialService.addProvider: provider with this origin already exists");
+
+ // enable the api when a provider is enabled
+ let provider = new SocialProvider(manifest);
+ SocialServiceInternal.providers[provider.origin] = provider;
+ ActiveProviders.add(provider.origin);
+
+ this.getOrderedProviderList(function (providers) {
+ this._notifyProviderListeners("provider-enabled", provider.origin, providers);
+ if (onDone)
+ onDone(provider);
+ }.bind(this));
+ },
+
+ // Removes a provider with the given origin, and notifies when the removal is
+ // complete.
+ disableProvider: function disableProvider(origin, onDone) {
+ if (!(origin in SocialServiceInternal.providers))
+ throw new Error("SocialService.disableProvider: no provider with origin " + origin + " exists!");
+
+ let provider = SocialServiceInternal.providers[origin];
+ let manifest = SocialService.getManifestByOrigin(origin);
+ let addon = manifest && new AddonWrapper(manifest);
+ if (addon) {
+ AddonManagerPrivate.callAddonListeners("onDisabling", addon, false);
+ addon.pendingOperations |= AddonManager.PENDING_DISABLE;
+ }
+ provider.enabled = false;
+
+ ActiveProviders.delete(provider.origin);
+
+ delete SocialServiceInternal.providers[origin];
+
+ if (addon) {
+ // we have to do this now so the addon manager ui will update an uninstall
+ // correctly.
+ addon.pendingOperations -= AddonManager.PENDING_DISABLE;
+ AddonManagerPrivate.callAddonListeners("onDisabled", addon);
+ }
+
+ this.getOrderedProviderList(function (providers) {
+ this._notifyProviderListeners("provider-disabled", origin, providers);
+ if (onDone)
+ onDone();
+ }.bind(this));
+ },
+
+ // Returns a single provider object with the specified origin. The provider
+ // must be "installed" (ie, in ActiveProviders)
+ getProvider: function getProvider(origin, onDone) {
+ schedule((function () {
+ onDone(SocialServiceInternal.providers[origin] || null);
+ }).bind(this));
+ },
+
+ // Returns an unordered array of installed providers
+ getProviderList: function(onDone) {
+ schedule(function () {
+ onDone(SocialServiceInternal.providerArray);
+ });
+ },
+
+ getManifestByOrigin: function(origin) {
+ for (let manifest of SocialServiceInternal.manifests) {
+ if (origin == manifest.origin) {
+ return manifest;
+ }
+ }
+ return null;
+ },
+
+ // Returns an array of installed providers, sorted by frecency
+ getOrderedProviderList: function(onDone) {
+ SocialServiceInternal.orderedProviders(onDone);
+ },
+
+ getOriginActivationType: function (origin) {
+ return getOriginActivationType(origin);
+ },
+
+ _providerListeners: new Map(),
+ registerProviderListener: function registerProviderListener(listener) {
+ this._providerListeners.set(listener, 1);
+ },
+ unregisterProviderListener: function unregisterProviderListener(listener) {
+ this._providerListeners.delete(listener);
+ },
+
+ _notifyProviderListeners: function (topic, origin, providers) {
+ for (let [listener, ] of this._providerListeners) {
+ try {
+ listener(topic, origin, providers);
+ } catch (ex) {
+ Components.utils.reportError("SocialService: provider listener threw an exception: " + ex);
+ }
+ }
+ },
+
+ _manifestFromData: function(type, data, installOrigin) {
+ let featureURLs = ['shareURL'];
+ let resolveURLs = featureURLs.concat(['postActivationURL']);
+
+ if (type == 'directory' || type == 'internal') {
+ // directory provided manifests must have origin in manifest, use that
+ if (!data['origin']) {
+ Cu.reportError("SocialService.manifestFromData directory service provided manifest without origin.");
+ return null;
+ }
+ installOrigin = data.origin;
+ }
+ // force/fixup origin
+ let URI = Services.io.newURI(installOrigin, null, null);
+ let principal = Services.scriptSecurityManager.createCodebasePrincipal(URI, {});
+ data.origin = principal.origin;
+
+ // iconURL and name are required
+ let providerHasFeatures = featureURLs.some(url => data[url]);
+ if (!providerHasFeatures) {
+ Cu.reportError("SocialService.manifestFromData manifest missing required urls.");
+ return null;
+ }
+ if (!data['name'] || !data['iconURL']) {
+ Cu.reportError("SocialService.manifestFromData manifest missing name or iconURL.");
+ return null;
+ }
+ for (let url of resolveURLs) {
+ if (data[url]) {
+ try {
+ let resolved = Services.io.newURI(principal.URI.resolve(data[url]), null, null);
+ if (!(resolved.schemeIs("http") || resolved.schemeIs("https"))) {
+ Cu.reportError("SocialService.manifestFromData unsupported scheme '" + resolved.scheme + "' for " + principal.origin);
+ return null;
+ }
+ data[url] = resolved.spec;
+ } catch (e) {
+ Cu.reportError("SocialService.manifestFromData unable to resolve '" + url + "' for " + principal.origin);
+ return null;
+ }
+ }
+ }
+ return data;
+ },
+
+ _showInstallNotification: function(data, aAddonInstaller) {
+ let brandBundle = Services.strings.createBundle("chrome://branding/locale/brand.properties");
+ let browserBundle = Services.strings.createBundle("chrome://browser/locale/browser.properties");
+
+ // internal/directory activations need to use the manifest origin, any other
+ // use the domain activation is occurring on
+ let url = data.url;
+ if (data.installType == "internal" || data.installType == "directory") {
+ url = data.manifest.origin;
+ }
+ let requestingURI = Services.io.newURI(url, null, null);
+ let productName = brandBundle.GetStringFromName("brandShortName");
+
+ let message = browserBundle.formatStringFromName("service.install.description",
+ [requestingURI.host, productName], 2);
+
+ let action = {
+ label: browserBundle.GetStringFromName("service.install.ok.label"),
+ accessKey: browserBundle.GetStringFromName("service.install.ok.accesskey"),
+ callback: function() {
+ aAddonInstaller.install();
+ },
+ };
+
+ let options = {
+ learnMoreURL: Services.urlFormatter.formatURLPref("app.support.baseURL") + "social-api",
+ };
+ let anchor = "servicesInstall-notification-icon";
+ let notificationid = "servicesInstall";
+ data.window.PopupNotifications.show(data.window.gBrowser.selectedBrowser,
+ notificationid, message, anchor,
+ action, [], options);
+ },
+
+ installProvider: function(data, installCallback, options={}) {
+ data.installType = getOriginActivationType(data.origin);
+ // if we get data, we MUST have a valid manifest generated from the data
+ let manifest = this._manifestFromData(data.installType, data.manifest, data.origin);
+ if (!manifest)
+ throw new Error("SocialService.installProvider: service configuration is invalid from " + data.url);
+
+ let addon = new AddonWrapper(manifest);
+ if (addon && addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED)
+ throw new Error("installProvider: provider with origin [" +
+ data.origin + "] is blocklisted");
+ // manifestFromData call above will enforce correct origin. To support
+ // activation from about: uris, we need to be sure to use the updated
+ // origin on the manifest.
+ data.manifest = manifest;
+ let id = getAddonIDFromOrigin(manifest.origin);
+ AddonManager.getAddonByID(id, function(aAddon) {
+ if (aAddon && aAddon.userDisabled) {
+ aAddon.cancelUninstall();
+ aAddon.userDisabled = false;
+ }
+ schedule(function () {
+ try {
+ this._installProvider(data, options, aManifest => {
+ this._notifyProviderListeners("provider-installed", aManifest.origin);
+ installCallback(aManifest);
+ });
+ } catch (e) {
+ Cu.reportError("Activation failed: " + e);
+ installCallback(null);
+ }
+ }.bind(this));
+ }.bind(this));
+ },
+
+ _installProvider: function(data, options, installCallback) {
+ if (!data.manifest)
+ throw new Error("Cannot install provider without manifest data");
+
+ if (data.installType == "foreign" && !Services.prefs.getBoolPref("social.remote-install.enabled"))
+ throw new Error("Remote install of services is disabled");
+
+ // if installing from any website, the install must happen over https.
+ // "internal" are installs from about:home or similar
+ if (data.installType != "internal" && !Services.io.newURI(data.origin, null, null).schemeIs("https")) {
+ throw new Error("attempt to activate provider over unsecured channel: " + data.origin);
+ }
+
+ let installer = new AddonInstaller(data.url, data.manifest, installCallback);
+ let bypassPanel = options.bypassInstallPanel ||
+ (data.installType == "internal" && data.manifest.oneclick);
+ if (bypassPanel)
+ installer.install();
+ else
+ this._showInstallNotification(data, installer);
+ },
+
+ createWrapper: function(manifest) {
+ return new AddonWrapper(manifest);
+ },
+
+ /**
+ * updateProvider is used from the worker to self-update. Since we do not
+ * have knowledge of the currently selected provider here, we will notify
+ * the front end to deal with any reload.
+ */
+ updateProvider: function(aUpdateOrigin, aManifest) {
+ let installType = this.getOriginActivationType(aUpdateOrigin);
+ // if we get data, we MUST have a valid manifest generated from the data
+ let manifest = this._manifestFromData(installType, aManifest, aUpdateOrigin);
+ if (!manifest)
+ throw new Error("SocialService.installProvider: service configuration is invalid from " + aUpdateOrigin);
+
+ // overwrite the preference
+ let string = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ string.data = JSON.stringify(manifest);
+ Services.prefs.setComplexValue(getPrefnameFromOrigin(manifest.origin), Ci.nsISupportsString, string);
+
+ // overwrite the existing provider then notify the front end so it can
+ // handle any reload that might be necessary.
+ if (ActiveProviders.has(manifest.origin)) {
+ let provider = SocialServiceInternal.providers[manifest.origin];
+ provider.enabled = false;
+ provider = new SocialProvider(manifest);
+ SocialServiceInternal.providers[provider.origin] = provider;
+ // update the cache and ui, reload provider if necessary
+ this.getOrderedProviderList(providers => {
+ this._notifyProviderListeners("provider-update", provider.origin, providers);
+ });
+ }
+
+ },
+
+ uninstallProvider: function(origin, aCallback) {
+ let manifest = SocialService.getManifestByOrigin(origin);
+ let addon = new AddonWrapper(manifest);
+ addon.uninstall(aCallback);
+ }
+};
+
+/**
+ * The SocialProvider object represents a social provider.
+ *
+ * @constructor
+ * @param {jsobj} object representing the manifest file describing this provider
+ * @param {bool} boolean indicating whether this provider is "built in"
+ */
+function SocialProvider(input) {
+ if (!input.name)
+ throw new Error("SocialProvider must be passed a name");
+ if (!input.origin)
+ throw new Error("SocialProvider must be passed an origin");
+
+ let addon = new AddonWrapper(input);
+ if (addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED)
+ throw new Error("SocialProvider: provider with origin [" +
+ input.origin + "] is blocklisted");
+
+ this.name = input.name;
+ this.iconURL = input.iconURL;
+ this.icon32URL = input.icon32URL;
+ this.icon64URL = input.icon64URL;
+ this.shareURL = input.shareURL;
+ this.postActivationURL = input.postActivationURL;
+ this.origin = input.origin;
+ let originUri = Services.io.newURI(input.origin, null, null);
+ this.principal = Services.scriptSecurityManager.createCodebasePrincipal(originUri, {});
+ this.ambientNotificationIcons = {};
+ this.errorState = null;
+ this.frecency = 0;
+
+ try {
+ this.domain = etld.getBaseDomainFromHost(originUri.host);
+ } catch (e) {
+ this.domain = originUri.host;
+ }
+}
+
+SocialProvider.prototype = {
+ reload: function() {
+ // calling terminate/activate does not set the enabled state whereas setting
+ // enabled will call terminate/activate
+ this.enabled = false;
+ this.enabled = true;
+ Services.obs.notifyObservers(null, "social:provider-reload", this.origin);
+ },
+
+ // Provider enabled/disabled state.
+ _enabled: false,
+ get enabled() {
+ return this._enabled;
+ },
+ set enabled(val) {
+ let enable = !!val;
+ if (enable == this._enabled)
+ return;
+
+ this._enabled = enable;
+
+ if (enable) {
+ this._activate();
+ } else {
+ this._terminate();
+ }
+ },
+
+ get manifest() {
+ return SocialService.getManifestByOrigin(this.origin);
+ },
+
+ getPageSize: function(name) {
+ let manifest = this.manifest;
+ if (manifest && manifest.pageSize)
+ return manifest.pageSize[name];
+ return undefined;
+ },
+
+ // Internal helper methods
+ _activate: function _activate() {
+ },
+
+ _terminate: function _terminate() {
+ this.errorState = null;
+ },
+
+ /**
+ * Checks if a given URI is of the same origin as the provider.
+ *
+ * Returns true or false.
+ *
+ * @param {URI or string} uri
+ */
+ isSameOrigin: function isSameOrigin(uri, allowIfInheritsPrincipal) {
+ if (!uri)
+ return false;
+ if (typeof uri == "string") {
+ try {
+ uri = Services.io.newURI(uri, null, null);
+ } catch (ex) {
+ // an invalid URL can't be loaded!
+ return false;
+ }
+ }
+ try {
+ this.principal.checkMayLoad(
+ uri, // the thing to check.
+ false, // reportError - we do our own reporting when necessary.
+ allowIfInheritsPrincipal
+ );
+ return true;
+ } catch (ex) {
+ return false;
+ }
+ },
+
+ /**
+ * Resolve partial URLs for a provider.
+ *
+ * Returns nsIURI object or null on failure
+ *
+ * @param {string} url
+ */
+ resolveUri: function resolveUri(url) {
+ try {
+ let fullURL = this.principal.URI.resolve(url);
+ return Services.io.newURI(fullURL, null, null);
+ } catch (ex) {
+ Cu.reportError("mozSocial: failed to resolve window URL: " + url + "; " + ex);
+ return null;
+ }
+ }
+};
+
+function getAddonIDFromOrigin(origin) {
+ let originUri = Services.io.newURI(origin, null, null);
+ return originUri.host + ID_SUFFIX;
+}
+
+function getPrefnameFromOrigin(origin) {
+ return "social.manifest." + SocialServiceInternal.getManifestPrefname(origin);
+}
+
+function AddonInstaller(sourceURI, aManifest, installCallback) {
+ aManifest.updateDate = Date.now();
+ // get the existing manifest for installDate
+ let manifest = SocialService.getManifestByOrigin(aManifest.origin);
+ let isNewInstall = !manifest;
+ if (manifest && manifest.installDate)
+ aManifest.installDate = manifest.installDate;
+ else
+ aManifest.installDate = aManifest.updateDate;
+
+ this.sourceURI = sourceURI;
+ this.install = function() {
+ let addon = this.addon;
+ if (isNewInstall) {
+ AddonManagerPrivate.callInstallListeners("onExternalInstall", null, addon, null, false);
+ AddonManagerPrivate.callAddonListeners("onInstalling", addon, false);
+ }
+
+ let string = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ string.data = JSON.stringify(aManifest);
+ Services.prefs.setComplexValue(getPrefnameFromOrigin(aManifest.origin), Ci.nsISupportsString, string);
+
+ if (isNewInstall) {
+ AddonManagerPrivate.callAddonListeners("onInstalled", addon);
+ }
+ installCallback(aManifest);
+ };
+ this.cancel = function() {
+ Services.prefs.clearUserPref(getPrefnameFromOrigin(aManifest.origin));
+ };
+ this.addon = new AddonWrapper(aManifest);
+}
+
+var SocialAddonProvider = {
+ startup: function() {},
+
+ shutdown: function() {},
+
+ updateAddonAppDisabledStates: function() {
+ // we wont bother with "enabling" services that are released from blocklist
+ for (let manifest of SocialServiceInternal.manifests) {
+ try {
+ if (ActiveProviders.has(manifest.origin)) {
+ let addon = new AddonWrapper(manifest);
+ if (addon.blocklistState != Ci.nsIBlocklistService.STATE_NOT_BLOCKED) {
+ SocialService.disableProvider(manifest.origin);
+ }
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ },
+
+ getAddonByID: function(aId, aCallback) {
+ for (let manifest of SocialServiceInternal.manifests) {
+ if (aId == getAddonIDFromOrigin(manifest.origin)) {
+ aCallback(new AddonWrapper(manifest));
+ return;
+ }
+ }
+ aCallback(null);
+ },
+
+ getAddonsByTypes: function(aTypes, aCallback) {
+ if (aTypes && aTypes.indexOf(ADDON_TYPE_SERVICE) == -1) {
+ aCallback([]);
+ return;
+ }
+ aCallback([...SocialServiceInternal.manifests].map(a => new AddonWrapper(a)));
+ },
+
+ removeAddon: function(aAddon, aCallback) {
+ AddonManagerPrivate.callAddonListeners("onUninstalling", aAddon, false);
+ aAddon.pendingOperations |= AddonManager.PENDING_UNINSTALL;
+ Services.prefs.clearUserPref(getPrefnameFromOrigin(aAddon.manifest.origin));
+ aAddon.pendingOperations -= AddonManager.PENDING_UNINSTALL;
+ AddonManagerPrivate.callAddonListeners("onUninstalled", aAddon);
+ SocialService._notifyProviderListeners("provider-uninstalled", aAddon.manifest.origin);
+ if (aCallback)
+ schedule(aCallback);
+ }
+};
+
+
+function AddonWrapper(aManifest) {
+ this.manifest = aManifest;
+ this.id = getAddonIDFromOrigin(this.manifest.origin);
+ this._pending = AddonManager.PENDING_NONE;
+}
+AddonWrapper.prototype = {
+ get type() {
+ return ADDON_TYPE_SERVICE;
+ },
+
+ get appDisabled() {
+ return this.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED;
+ },
+
+ set softDisabled(val) {
+ this.userDisabled = val;
+ },
+
+ get softDisabled() {
+ return this.userDisabled;
+ },
+
+ get isCompatible() {
+ return true;
+ },
+
+ get isPlatformCompatible() {
+ return true;
+ },
+
+ get scope() {
+ return AddonManager.SCOPE_PROFILE;
+ },
+
+ get foreignInstall() {
+ return false;
+ },
+
+ isCompatibleWith: function(appVersion, platformVersion) {
+ return true;
+ },
+
+ get providesUpdatesSecurely() {
+ return true;
+ },
+
+ get blocklistState() {
+ return Services.blocklist.getAddonBlocklistState(this);
+ },
+
+ get blocklistURL() {
+ return Services.blocklist.getAddonBlocklistURL(this);
+ },
+
+ get screenshots() {
+ return [];
+ },
+
+ get pendingOperations() {
+ return this._pending || AddonManager.PENDING_NONE;
+ },
+ set pendingOperations(val) {
+ this._pending = val;
+ },
+
+ get operationsRequiringRestart() {
+ return AddonManager.OP_NEEDS_RESTART_NONE;
+ },
+
+ get size() {
+ return null;
+ },
+
+ get permissions() {
+ let permissions = 0;
+ // any "user defined" manifest can be removed
+ if (Services.prefs.prefHasUserValue(getPrefnameFromOrigin(this.manifest.origin)))
+ permissions = AddonManager.PERM_CAN_UNINSTALL;
+ if (!this.appDisabled) {
+ if (this.userDisabled) {
+ permissions |= AddonManager.PERM_CAN_ENABLE;
+ } else {
+ permissions |= AddonManager.PERM_CAN_DISABLE;
+ }
+ }
+ return permissions;
+ },
+
+ findUpdates: function(listener, reason, appVersion, platformVersion) {
+ if ("onNoCompatibilityUpdateAvailable" in listener)
+ listener.onNoCompatibilityUpdateAvailable(this);
+ if ("onNoUpdateAvailable" in listener)
+ listener.onNoUpdateAvailable(this);
+ if ("onUpdateFinished" in listener)
+ listener.onUpdateFinished(this);
+ },
+
+ get isActive() {
+ return ActiveProviders.has(this.manifest.origin);
+ },
+
+ get name() {
+ return this.manifest.name;
+ },
+ get version() {
+ return this.manifest.version ? this.manifest.version.toString() : "";
+ },
+
+ get iconURL() {
+ return this.manifest.icon32URL ? this.manifest.icon32URL : this.manifest.iconURL;
+ },
+ get icon64URL() {
+ return this.manifest.icon64URL;
+ },
+ get icons() {
+ let icons = {
+ 16: this.manifest.iconURL
+ };
+ if (this.manifest.icon32URL)
+ icons[32] = this.manifest.icon32URL;
+ if (this.manifest.icon64URL)
+ icons[64] = this.manifest.icon64URL;
+ return icons;
+ },
+
+ get description() {
+ return this.manifest.description;
+ },
+ get homepageURL() {
+ return this.manifest.homepageURL;
+ },
+ get defaultLocale() {
+ return this.manifest.defaultLocale;
+ },
+ get selectedLocale() {
+ return this.manifest.selectedLocale;
+ },
+
+ get installDate() {
+ return this.manifest.installDate ? new Date(this.manifest.installDate) : null;
+ },
+ get updateDate() {
+ return this.manifest.updateDate ? new Date(this.manifest.updateDate) : null;
+ },
+
+ get creator() {
+ return new AddonManagerPrivate.AddonAuthor(this.manifest.author);
+ },
+
+ get userDisabled() {
+ return this.appDisabled || !ActiveProviders.has(this.manifest.origin);
+ },
+
+ set userDisabled(val) {
+ if (val == this.userDisabled)
+ return val;
+ if (val) {
+ SocialService.disableProvider(this.manifest.origin);
+ } else if (!this.appDisabled) {
+ SocialService.enableProvider(this.manifest.origin);
+ }
+ return val;
+ },
+
+ uninstall: function(aCallback) {
+ let prefName = getPrefnameFromOrigin(this.manifest.origin);
+ if (Services.prefs.prefHasUserValue(prefName)) {
+ if (ActiveProviders.has(this.manifest.origin)) {
+ SocialService.disableProvider(this.manifest.origin, function() {
+ SocialAddonProvider.removeAddon(this, aCallback);
+ }.bind(this));
+ } else {
+ SocialAddonProvider.removeAddon(this, aCallback);
+ }
+ } else {
+ schedule(aCallback);
+ }
+ },
+
+ cancelUninstall: function() {
+ this._pending -= AddonManager.PENDING_UNINSTALL;
+ AddonManagerPrivate.callAddonListeners("onOperationCancelled", this);
+ }
+};
+
+
+AddonManagerPrivate.registerProvider(SocialAddonProvider, [
+ new AddonManagerPrivate.AddonType(ADDON_TYPE_SERVICE, URI_EXTENSION_STRINGS,
+ STRING_TYPE_NAME,
+ AddonManager.VIEW_TYPE_LIST, 10000)
+]);
diff --git a/browser/modules/TransientPrefs.jsm b/browser/modules/TransientPrefs.jsm
new file mode 100644
index 000000000..30ba6a152
--- /dev/null
+++ b/browser/modules/TransientPrefs.jsm
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["TransientPrefs"];
+
+Components.utils.import("resource://gre/modules/Preferences.jsm");
+
+var prefVisibility = new Map;
+
+/* Use for preferences that should only be visible when they've been modified.
+ When reset to their default state, they remain visible until restarting the
+ application. */
+
+this.TransientPrefs = {
+ prefShouldBeVisible: function (prefName) {
+ if (Preferences.isSet(prefName))
+ prefVisibility.set(prefName, true);
+
+ return !!prefVisibility.get(prefName);
+ }
+};
diff --git a/browser/modules/URLBarZoom.jsm b/browser/modules/URLBarZoom.jsm
new file mode 100644
index 000000000..3e1c0f707
--- /dev/null
+++ b/browser/modules/URLBarZoom.jsm
@@ -0,0 +1,51 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [ "URLBarZoom" ];
+
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+var URLBarZoom = {
+
+ init: function(aWindow) {
+ // Register ourselves with the service so we know when the zoom prefs change.
+ Services.obs.addObserver(updateZoomButton, "browser-fullZoom:zoomChange", false);
+ Services.obs.addObserver(updateZoomButton, "browser-fullZoom:zoomReset", false);
+ Services.obs.addObserver(updateZoomButton, "browser-fullZoom:location-change", false);
+ },
+}
+
+function updateZoomButton(aSubject, aTopic) {
+ let win = aSubject.ownerDocument.defaultView;
+ let customizableZoomControls = win.document.getElementById("zoom-controls");
+ let zoomResetButton = win.document.getElementById("urlbar-zoom-button");
+ let zoomFactor = Math.round(win.ZoomManager.zoom * 100);
+
+ // Ensure that zoom controls haven't already been added to browser in Customize Mode
+ if (customizableZoomControls &&
+ customizableZoomControls.getAttribute("cui-areatype") == "toolbar") {
+ zoomResetButton.hidden = true;
+ return;
+ }
+ if (zoomFactor != 100) {
+ // Check if zoom button is visible and update label if it is
+ if (zoomResetButton.hidden) {
+ zoomResetButton.hidden = false;
+ }
+ // Only allow pulse animation for zoom changes, not tab switching
+ if (aTopic != "browser-fullZoom:location-change") {
+ zoomResetButton.setAttribute("animate", "true");
+ } else {
+ zoomResetButton.removeAttribute("animate");
+ }
+ zoomResetButton.setAttribute("label",
+ win.gNavigatorBundle.getFormattedString("urlbar-zoom-button.label", [zoomFactor]));
+ // Hide button if zoom is at 100%
+ } else {
+ zoomResetButton.hidden = true;
+ }
+}
diff --git a/browser/modules/Windows8WindowFrameColor.jsm b/browser/modules/Windows8WindowFrameColor.jsm
new file mode 100644
index 000000000..911333747
--- /dev/null
+++ b/browser/modules/Windows8WindowFrameColor.jsm
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+const {interfaces: Ci, utils: Cu} = Components;
+
+this.EXPORTED_SYMBOLS = ["Windows8WindowFrameColor"];
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+var Registry = Cu.import("resource://gre/modules/WindowsRegistry.jsm").WindowsRegistry;
+
+var Windows8WindowFrameColor = {
+ _windowFrameColor: null,
+
+ get: function() {
+ if (this._windowFrameColor)
+ return this._windowFrameColor;
+
+ const HKCU = Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER;
+ const dwmKey = "Software\\Microsoft\\Windows\\DWM";
+ let customizationColor = Registry.readRegKey(HKCU, dwmKey,
+ "ColorizationColor");
+ if (customizationColor == undefined) {
+ // Seems to be the default color (hardcoded because of bug 1065998)
+ return [158, 158, 158];
+ }
+
+ // The color returned from the Registry is in decimal form.
+ let customizationColorHex = customizationColor.toString(16);
+
+ // Zero-pad the number just to make sure that it is 8 digits.
+ customizationColorHex = ("00000000" + customizationColorHex).substr(-8);
+ let customizationColorArray = customizationColorHex.match(/../g);
+ let [, fgR, fgG, fgB] = customizationColorArray.map(val => parseInt(val, 16));
+ let colorizationColorBalance = Registry.readRegKey(HKCU, dwmKey,
+ "ColorizationColorBalance");
+ if (colorizationColorBalance == undefined) {
+ colorizationColorBalance = 78;
+ }
+
+ // Window frame base color when Color Intensity is at 0, see bug 1004576.
+ let frameBaseColor = 217;
+ let alpha = colorizationColorBalance / 100;
+
+ // Alpha-blend the foreground color with the frame base color.
+ let r = Math.round(fgR * alpha + frameBaseColor * (1 - alpha));
+ let g = Math.round(fgG * alpha + frameBaseColor * (1 - alpha));
+ let b = Math.round(fgB * alpha + frameBaseColor * (1 - alpha));
+ return this._windowFrameColor = [r, g, b];
+ },
+};
diff --git a/browser/modules/WindowsJumpLists.jsm b/browser/modules/WindowsJumpLists.jsm
new file mode 100644
index 000000000..4df87b47a
--- /dev/null
+++ b/browser/modules/WindowsJumpLists.jsm
@@ -0,0 +1,579 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+/**
+ * Constants
+ */
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+
+// Stop updating jumplists after some idle time.
+const IDLE_TIMEOUT_SECONDS = 5 * 60;
+
+// Prefs
+const PREF_TASKBAR_BRANCH = "browser.taskbar.lists.";
+const PREF_TASKBAR_ENABLED = "enabled";
+const PREF_TASKBAR_ITEMCOUNT = "maxListItemCount";
+const PREF_TASKBAR_FREQUENT = "frequent.enabled";
+const PREF_TASKBAR_RECENT = "recent.enabled";
+const PREF_TASKBAR_TASKS = "tasks.enabled";
+const PREF_TASKBAR_REFRESH = "refreshInSeconds";
+
+// Hash keys for pendingStatements.
+const LIST_TYPE = {
+ FREQUENT: 0
+, RECENT: 1
+}
+
+/**
+ * Exports
+ */
+
+this.EXPORTED_SYMBOLS = [
+ "WinTaskbarJumpList",
+];
+
+/**
+ * Smart getters
+ */
+
+XPCOMUtils.defineLazyGetter(this, "_prefs", function() {
+ return Services.prefs.getBranch(PREF_TASKBAR_BRANCH);
+});
+
+XPCOMUtils.defineLazyGetter(this, "_stringBundle", function() {
+ return Services.strings
+ .createBundle("chrome://browser/locale/taskbar.properties");
+});
+
+XPCOMUtils.defineLazyGetter(this, "PlacesUtils", function() {
+ Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
+ return PlacesUtils;
+});
+
+XPCOMUtils.defineLazyGetter(this, "NetUtil", function() {
+ Components.utils.import("resource://gre/modules/NetUtil.jsm");
+ return NetUtil;
+});
+
+XPCOMUtils.defineLazyServiceGetter(this, "_idle",
+ "@mozilla.org/widget/idleservice;1",
+ "nsIIdleService");
+
+XPCOMUtils.defineLazyServiceGetter(this, "_taskbarService",
+ "@mozilla.org/windows-taskbar;1",
+ "nsIWinTaskbar");
+
+XPCOMUtils.defineLazyServiceGetter(this, "_winShellService",
+ "@mozilla.org/browser/shell-service;1",
+ "nsIWindowsShellService");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+
+/**
+ * Global functions
+ */
+
+function _getString(name) {
+ return _stringBundle.GetStringFromName(name);
+}
+
+// Task list configuration data object.
+
+var tasksCfg = [
+ /**
+ * Task configuration options: title, description, args, iconIndex, open, close.
+ *
+ * title - Task title displayed in the list. (strings in the table are temp fillers.)
+ * description - Tooltip description on the list item.
+ * args - Command line args to invoke the task.
+ * iconIndex - Optional win icon index into the main application for the
+ * list item.
+ * open - Boolean indicates if the command should be visible after the browser opens.
+ * close - Boolean indicates if the command should be visible after the browser closes.
+ */
+ // Open new tab
+ {
+ get title() { return _getString("taskbar.tasks.newTab.label"); },
+ get description() { return _getString("taskbar.tasks.newTab.description"); },
+ args: "-new-tab about:blank",
+ iconIndex: 3, // New window icon
+ open: true,
+ close: true, // The jump list already has an app launch icon, but
+ // we don't always update the list on shutdown.
+ // Thus true for consistency.
+ },
+
+ // Open new window
+ {
+ get title() { return _getString("taskbar.tasks.newWindow.label"); },
+ get description() { return _getString("taskbar.tasks.newWindow.description"); },
+ args: "-browser",
+ iconIndex: 2, // New tab icon
+ open: true,
+ close: true, // No point, but we don't always update the list on
+ // shutdown. Thus true for consistency.
+ },
+
+ // Open new private window
+ {
+ get title() { return _getString("taskbar.tasks.newPrivateWindow.label"); },
+ get description() { return _getString("taskbar.tasks.newPrivateWindow.description"); },
+ args: "-private-window",
+ iconIndex: 4, // Private browsing mode icon
+ open: true,
+ close: true, // No point, but we don't always update the list on
+ // shutdown. Thus true for consistency.
+ },
+];
+
+// Implementation
+
+this.WinTaskbarJumpList =
+{
+ _builder: null,
+ _tasks: null,
+ _shuttingDown: false,
+
+ /**
+ * Startup, shutdown, and update
+ */
+
+ startup: function WTBJL_startup() {
+ // exit if this isn't win7 or higher.
+ if (!this._initTaskbar())
+ return;
+
+ // Win shell shortcut maintenance. If we've gone through an update,
+ // this will update any pinned taskbar shortcuts. Not specific to
+ // jump lists, but this was a convienent place to call it.
+ try {
+ // dev builds may not have helper.exe, ignore failures.
+ this._shortcutMaintenance();
+ } catch (ex) {
+ }
+
+ // Store our task list config data
+ this._tasks = tasksCfg;
+
+ // retrieve taskbar related prefs.
+ this._refreshPrefs();
+
+ // observer for private browsing and our prefs branch
+ this._initObs();
+
+ // jump list refresh timer
+ this._updateTimer();
+ },
+
+ update: function WTBJL_update() {
+ // are we disabled via prefs? don't do anything!
+ if (!this._enabled)
+ return;
+
+ // do what we came here to do, update the taskbar jumplist
+ this._buildList();
+ },
+
+ _shutdown: function WTBJL__shutdown() {
+ this._shuttingDown = true;
+
+ // Correctly handle a clear history on shutdown. If there are no
+ // entries be sure to empty all history lists. Luckily Places caches
+ // this value, so it's a pretty fast call.
+ if (!PlacesUtils.history.hasHistoryEntries) {
+ this.update();
+ }
+
+ this._free();
+ },
+
+ _shortcutMaintenance: function WTBJL__maintenace() {
+ _winShellService.shortcutMaintenance();
+ },
+
+ /**
+ * List building
+ *
+ * @note Async builders must add their mozIStoragePendingStatement to
+ * _pendingStatements object, using a different LIST_TYPE entry for
+ * each statement. Once finished they must remove it and call
+ * commitBuild(). When there will be no more _pendingStatements,
+ * commitBuild() will commit for real.
+ */
+
+ _pendingStatements: {},
+ _hasPendingStatements: function WTBJL__hasPendingStatements() {
+ return Object.keys(this._pendingStatements).length > 0;
+ },
+
+ _buildList: function WTBJL__buildList() {
+ if (this._hasPendingStatements()) {
+ // We were requested to update the list while another update was in
+ // progress, this could happen at shutdown, idle or privatebrowsing.
+ // Abort the current list building.
+ for (let listType in this._pendingStatements) {
+ this._pendingStatements[listType].cancel();
+ delete this._pendingStatements[listType];
+ }
+ this._builder.abortListBuild();
+ }
+
+ // anything to build?
+ if (!this._showFrequent && !this._showRecent && !this._showTasks) {
+ // don't leave the last list hanging on the taskbar.
+ this._deleteActiveJumpList();
+ return;
+ }
+
+ if (!this._startBuild())
+ return;
+
+ if (this._showTasks)
+ this._buildTasks();
+
+ // Space for frequent items takes priority over recent.
+ if (this._showFrequent)
+ this._buildFrequent();
+
+ if (this._showRecent)
+ this._buildRecent();
+
+ this._commitBuild();
+ },
+
+ /**
+ * Taskbar api wrappers
+ */
+
+ _startBuild: function WTBJL__startBuild() {
+ var removedItems = Cc["@mozilla.org/array;1"].
+ createInstance(Ci.nsIMutableArray);
+ this._builder.abortListBuild();
+ if (this._builder.initListBuild(removedItems)) {
+ // Prior to building, delete removed items from history.
+ this._clearHistory(removedItems);
+ return true;
+ }
+ return false;
+ },
+
+ _commitBuild: function WTBJL__commitBuild() {
+ if (!this._hasPendingStatements() && !this._builder.commitListBuild()) {
+ this._builder.abortListBuild();
+ }
+ },
+
+ _buildTasks: function WTBJL__buildTasks() {
+ var items = Cc["@mozilla.org/array;1"].
+ createInstance(Ci.nsIMutableArray);
+ this._tasks.forEach(function (task) {
+ if ((this._shuttingDown && !task.close) || (!this._shuttingDown && !task.open))
+ return;
+ var item = this._getHandlerAppItem(task.title, task.description,
+ task.args, task.iconIndex, null);
+ items.appendElement(item, false);
+ }, this);
+
+ if (items.length > 0)
+ this._builder.addListToBuild(this._builder.JUMPLIST_CATEGORY_TASKS, items);
+ },
+
+ _buildCustom: function WTBJL__buildCustom(title, items) {
+ if (items.length > 0)
+ this._builder.addListToBuild(this._builder.JUMPLIST_CATEGORY_CUSTOMLIST, items, title);
+ },
+
+ _buildFrequent: function WTBJL__buildFrequent() {
+ // If history is empty, just bail out.
+ if (!PlacesUtils.history.hasHistoryEntries) {
+ return;
+ }
+
+ // Windows supports default frequent and recent lists,
+ // but those depend on internal windows visit tracking
+ // which we don't populate. So we build our own custom
+ // frequent and recent lists using our nav history data.
+
+ var items = Cc["@mozilla.org/array;1"].
+ createInstance(Ci.nsIMutableArray);
+ // track frequent items so that we don't add them to
+ // the recent list.
+ this._frequentHashList = [];
+
+ this._pendingStatements[LIST_TYPE.FREQUENT] = this._getHistoryResults(
+ Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING,
+ this._maxItemCount,
+ function (aResult) {
+ if (!aResult) {
+ delete this._pendingStatements[LIST_TYPE.FREQUENT];
+ // The are no more results, build the list.
+ this._buildCustom(_getString("taskbar.frequent.label"), items);
+ this._commitBuild();
+ return;
+ }
+
+ let title = aResult.title || aResult.uri;
+ let faviconPageUri = Services.io.newURI(aResult.uri, null, null);
+ let shortcut = this._getHandlerAppItem(title, title, aResult.uri, 1,
+ faviconPageUri);
+ items.appendElement(shortcut, false);
+ this._frequentHashList.push(aResult.uri);
+ },
+ this
+ );
+ },
+
+ _buildRecent: function WTBJL__buildRecent() {
+ // If history is empty, just bail out.
+ if (!PlacesUtils.history.hasHistoryEntries) {
+ return;
+ }
+
+ var items = Cc["@mozilla.org/array;1"].
+ createInstance(Ci.nsIMutableArray);
+ // Frequent items will be skipped, so we select a double amount of
+ // entries and stop fetching results at _maxItemCount.
+ var count = 0;
+
+ this._pendingStatements[LIST_TYPE.RECENT] = this._getHistoryResults(
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING,
+ this._maxItemCount * 2,
+ function (aResult) {
+ if (!aResult) {
+ // The are no more results, build the list.
+ this._buildCustom(_getString("taskbar.recent.label"), items);
+ delete this._pendingStatements[LIST_TYPE.RECENT];
+ this._commitBuild();
+ return;
+ }
+
+ if (count >= this._maxItemCount) {
+ return;
+ }
+
+ // Do not add items to recent that have already been added to frequent.
+ if (this._frequentHashList &&
+ this._frequentHashList.indexOf(aResult.uri) != -1) {
+ return;
+ }
+
+ let title = aResult.title || aResult.uri;
+ let faviconPageUri = Services.io.newURI(aResult.uri, null, null);
+ let shortcut = this._getHandlerAppItem(title, title, aResult.uri, 1,
+ faviconPageUri);
+ items.appendElement(shortcut, false);
+ count++;
+ },
+ this
+ );
+ },
+
+ _deleteActiveJumpList: function WTBJL__deleteAJL() {
+ this._builder.deleteActiveList();
+ },
+
+ /**
+ * Jump list item creation helpers
+ */
+
+ _getHandlerAppItem: function WTBJL__getHandlerAppItem(name, description,
+ args, iconIndex,
+ faviconPageUri) {
+ var file = Services.dirsvc.get("XREExeF", Ci.nsILocalFile);
+
+ var handlerApp = Cc["@mozilla.org/uriloader/local-handler-app;1"].
+ createInstance(Ci.nsILocalHandlerApp);
+ handlerApp.executable = file;
+ // handlers default to the leaf name if a name is not specified
+ if (name && name.length != 0)
+ handlerApp.name = name;
+ handlerApp.detailedDescription = description;
+ handlerApp.appendParameter(args);
+
+ var item = Cc["@mozilla.org/windows-jumplistshortcut;1"].
+ createInstance(Ci.nsIJumpListShortcut);
+ item.app = handlerApp;
+ item.iconIndex = iconIndex;
+ item.faviconPageUri = faviconPageUri;
+ return item;
+ },
+
+ _getSeparatorItem: function WTBJL__getSeparatorItem() {
+ var item = Cc["@mozilla.org/windows-jumplistseparator;1"].
+ createInstance(Ci.nsIJumpListSeparator);
+ return item;
+ },
+
+ /**
+ * Nav history helpers
+ */
+
+ _getHistoryResults:
+ function WTBLJL__getHistoryResults(aSortingMode, aLimit, aCallback, aScope) {
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.maxResults = aLimit;
+ options.sortingMode = aSortingMode;
+ var query = PlacesUtils.history.getNewQuery();
+
+ // Return the pending statement to the caller, to allow cancelation.
+ return PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .asyncExecuteLegacyQueries([query], 1, options, {
+ handleResult: function (aResultSet) {
+ for (let row; (row = aResultSet.getNextRow());) {
+ try {
+ aCallback.call(aScope,
+ { uri: row.getResultByIndex(1)
+ , title: row.getResultByIndex(2)
+ });
+ } catch (e) {}
+ }
+ },
+ handleError: function (aError) {
+ Components.utils.reportError(
+ "Async execution error (" + aError.result + "): " + aError.message);
+ },
+ handleCompletion: function (aReason) {
+ aCallback.call(WinTaskbarJumpList, null);
+ },
+ });
+ },
+
+ _clearHistory: function WTBJL__clearHistory(items) {
+ if (!items)
+ return;
+ var URIsToRemove = [];
+ var e = items.enumerate();
+ while (e.hasMoreElements()) {
+ let oldItem = e.getNext().QueryInterface(Ci.nsIJumpListShortcut);
+ if (oldItem) {
+ try { // in case we get a bad uri
+ let uriSpec = oldItem.app.getParameter(0);
+ URIsToRemove.push(NetUtil.newURI(uriSpec));
+ } catch (err) { }
+ }
+ }
+ if (URIsToRemove.length > 0) {
+ PlacesUtils.bhistory.removePages(URIsToRemove, URIsToRemove.length, true);
+ }
+ },
+
+ /**
+ * Prefs utilities
+ */
+
+ _refreshPrefs: function WTBJL__refreshPrefs() {
+ this._enabled = _prefs.getBoolPref(PREF_TASKBAR_ENABLED);
+ this._showFrequent = _prefs.getBoolPref(PREF_TASKBAR_FREQUENT);
+ this._showRecent = _prefs.getBoolPref(PREF_TASKBAR_RECENT);
+ this._showTasks = _prefs.getBoolPref(PREF_TASKBAR_TASKS);
+ this._maxItemCount = _prefs.getIntPref(PREF_TASKBAR_ITEMCOUNT);
+ },
+
+ /**
+ * Init and shutdown utilities
+ */
+
+ _initTaskbar: function WTBJL__initTaskbar() {
+ this._builder = _taskbarService.createJumpListBuilder();
+ if (!this._builder || !this._builder.available)
+ return false;
+
+ return true;
+ },
+
+ _initObs: function WTBJL__initObs() {
+ // If the browser is closed while in private browsing mode, the "exit"
+ // notification is fired on quit-application-granted.
+ // History cleanup can happen at profile-change-teardown.
+ Services.obs.addObserver(this, "profile-before-change", false);
+ Services.obs.addObserver(this, "browser:purge-session-history", false);
+ _prefs.addObserver("", this, false);
+ },
+
+ _freeObs: function WTBJL__freeObs() {
+ Services.obs.removeObserver(this, "profile-before-change");
+ Services.obs.removeObserver(this, "browser:purge-session-history");
+ _prefs.removeObserver("", this);
+ },
+
+ _updateTimer: function WTBJL__updateTimer() {
+ if (this._enabled && !this._shuttingDown && !this._timer) {
+ this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this._timer.initWithCallback(this,
+ _prefs.getIntPref(PREF_TASKBAR_REFRESH)*1000,
+ this._timer.TYPE_REPEATING_SLACK);
+ }
+ else if ((!this._enabled || this._shuttingDown) && this._timer) {
+ this._timer.cancel();
+ delete this._timer;
+ }
+ },
+
+ _hasIdleObserver: false,
+ _updateIdleObserver: function WTBJL__updateIdleObserver() {
+ if (this._enabled && !this._shuttingDown && !this._hasIdleObserver) {
+ _idle.addIdleObserver(this, IDLE_TIMEOUT_SECONDS);
+ this._hasIdleObserver = true;
+ }
+ else if ((!this._enabled || this._shuttingDown) && this._hasIdleObserver) {
+ _idle.removeIdleObserver(this, IDLE_TIMEOUT_SECONDS);
+ this._hasIdleObserver = false;
+ }
+ },
+
+ _free: function WTBJL__free() {
+ this._freeObs();
+ this._updateTimer();
+ this._updateIdleObserver();
+ delete this._builder;
+ },
+
+ /**
+ * Notification handlers
+ */
+
+ notify: function WTBJL_notify(aTimer) {
+ // Add idle observer on the first notification so it doesn't hit startup.
+ this._updateIdleObserver();
+ this.update();
+ },
+
+ observe: function WTBJL_observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "nsPref:changed":
+ if (this._enabled == true && !_prefs.getBoolPref(PREF_TASKBAR_ENABLED))
+ this._deleteActiveJumpList();
+ this._refreshPrefs();
+ this._updateTimer();
+ this._updateIdleObserver();
+ this.update();
+ break;
+
+ case "profile-before-change":
+ this._shutdown();
+ break;
+
+ case "browser:purge-session-history":
+ this.update();
+ break;
+ case "idle":
+ if (this._timer) {
+ this._timer.cancel();
+ delete this._timer;
+ }
+ break;
+
+ case "active":
+ this._updateTimer();
+ break;
+ }
+ },
+};
diff --git a/browser/modules/WindowsPreviewPerTab.jsm b/browser/modules/WindowsPreviewPerTab.jsm
new file mode 100644
index 000000000..6586b5d3b
--- /dev/null
+++ b/browser/modules/WindowsPreviewPerTab.jsm
@@ -0,0 +1,862 @@
+/* vim: se cin sw=2 ts=2 et filetype=javascript :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/*
+ * This module implements the front end behavior for AeroPeek. Starting in
+ * Windows Vista, the taskbar began showing live thumbnail previews of windows
+ * when the user hovered over the window icon in the taskbar. Starting with
+ * Windows 7, the taskbar allows an application to expose its tabbed interface
+ * in the taskbar by showing thumbnail previews rather than the default window
+ * preview. Additionally, when a user hovers over a thumbnail (tab or window),
+ * they are shown a live preview of the window (or tab + its containing window).
+ *
+ * In Windows 7, a title, icon, close button and optional toolbar are shown for
+ * each preview. This feature does not make use of the toolbar. For window
+ * previews, the title is the window title and the icon the window icon. For
+ * tab previews, the title is the page title and the page's favicon. In both
+ * cases, the close button "does the right thing."
+ *
+ * The primary objects behind this feature are nsITaskbarTabPreview and
+ * nsITaskbarPreviewController. Each preview has a controller. The controller
+ * responds to the user's interactions on the taskbar and provides the required
+ * data to the preview for determining the size of the tab and thumbnail. The
+ * PreviewController class implements this interface. The preview will request
+ * the controller to provide a thumbnail or preview when the user interacts with
+ * the taskbar. To reduce the overhead of drawing the tab area, the controller
+ * implementation caches the tab's contents in a <canvas> element. If no
+ * previews or thumbnails have been requested for some time, the controller will
+ * discard its cached tab contents.
+ *
+ * Screen real estate is limited so when there are too many thumbnails to fit
+ * on the screen, the taskbar stops displaying thumbnails and instead displays
+ * just the title, icon and close button in a similar fashion to previous
+ * versions of the taskbar. If there are still too many previews to fit on the
+ * screen, the taskbar resorts to a scroll up and scroll down button pair to let
+ * the user scroll through the list of tabs. Since this is undoubtedly
+ * inconvenient for users with many tabs, the AeroPeek objects turns off all of
+ * the tab previews. This tells the taskbar to revert to one preview per window.
+ * If the number of tabs falls below this magic threshold, the preview-per-tab
+ * behavior returns. There is no reliable way to determine when the scroll
+ * buttons appear on the taskbar, so a magic pref-controlled number determines
+ * when this threshold has been crossed.
+ */
+this.EXPORTED_SYMBOLS = ["AeroPeek"];
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+// Pref to enable/disable preview-per-tab
+const TOGGLE_PREF_NAME = "browser.taskbar.previews.enable";
+// Pref to determine the magic auto-disable threshold
+const DISABLE_THRESHOLD_PREF_NAME = "browser.taskbar.previews.max";
+// Pref to control the time in seconds that tab contents live in the cache
+const CACHE_EXPIRATION_TIME_PREF_NAME = "browser.taskbar.previews.cachetime";
+
+const WINTASKBAR_CONTRACTID = "@mozilla.org/windows-taskbar;1";
+
+// Various utility properties
+XPCOMUtils.defineLazyServiceGetter(this, "imgTools",
+ "@mozilla.org/image/tools;1",
+ "imgITools");
+XPCOMUtils.defineLazyModuleGetter(this, "PageThumbs",
+ "resource://gre/modules/PageThumbs.jsm");
+
+// nsIURI -> imgIContainer
+function _imageFromURI(uri, privateMode, callback) {
+ let channel = NetUtil.newChannel({
+ uri: uri,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE
+ });
+
+ try {
+ channel.QueryInterface(Ci.nsIPrivateBrowsingChannel);
+ channel.setPrivate(privateMode);
+ } catch (e) {
+ // Ignore channels which do not support nsIPrivateBrowsingChannel
+ }
+ NetUtil.asyncFetch(channel, function(inputStream, resultCode) {
+ if (!Components.isSuccessCode(resultCode))
+ return;
+ try {
+ let out_img = { value: null };
+ imgTools.decodeImageData(inputStream, channel.contentType, out_img);
+ callback(out_img.value);
+ } catch (e) {
+ // We failed, so use the default favicon (only if this wasn't the default
+ // favicon).
+ let defaultURI = PlacesUtils.favicons.defaultFavicon;
+ if (!defaultURI.equals(uri))
+ _imageFromURI(defaultURI, privateMode, callback);
+ }
+ });
+}
+
+// string? -> imgIContainer
+function getFaviconAsImage(iconurl, privateMode, callback) {
+ if (iconurl) {
+ _imageFromURI(NetUtil.newURI(iconurl), privateMode, callback);
+ } else {
+ _imageFromURI(PlacesUtils.favicons.defaultFavicon, privateMode, callback);
+ }
+}
+
+// Snaps the given rectangle to be pixel-aligned at the given scale
+function snapRectAtScale(r, scale) {
+ let x = Math.floor(r.x * scale);
+ let y = Math.floor(r.y * scale);
+ let width = Math.ceil((r.x + r.width) * scale) - x;
+ let height = Math.ceil((r.y + r.height) * scale) - y;
+
+ r.x = x / scale;
+ r.y = y / scale;
+ r.width = width / scale;
+ r.height = height / scale;
+}
+
+// PreviewController
+
+/*
+ * This class manages the behavior of thumbnails and previews. It has the following
+ * responsibilities:
+ * 1) responding to requests from Windows taskbar for a thumbnail or window
+ * preview.
+ * 2) listens for dom events that result in a thumbnail or window preview needing
+ * to be refresh, and communicates this to the taskbar.
+ * 3) Handles querying and returning to the taskbar new thumbnail or window
+ * preview images through PageThumbs.
+ *
+ * @param win
+ * The TabWindow (see below) that owns the preview that this controls
+ * @param tab
+ * The <tab> that this preview is associated with
+ */
+function PreviewController(win, tab) {
+ this.win = win;
+ this.tab = tab;
+ this.linkedBrowser = tab.linkedBrowser;
+ this.preview = this.win.createTabPreview(this);
+
+ this.tab.addEventListener("TabAttrModified", this, false);
+
+ XPCOMUtils.defineLazyGetter(this, "canvasPreview", function () {
+ let canvas = PageThumbs.createCanvas();
+ canvas.mozOpaque = true;
+ return canvas;
+ });
+}
+
+PreviewController.prototype = {
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsITaskbarPreviewController,
+ Ci.nsIDOMEventListener]),
+
+ destroy: function () {
+ this.tab.removeEventListener("TabAttrModified", this, false);
+
+ // Break cycles, otherwise we end up leaking the window with everything
+ // attached to it.
+ delete this.win;
+ delete this.preview;
+ },
+
+ get wrappedJSObject() {
+ return this;
+ },
+
+ // Resizes the canvasPreview to 0x0, essentially freeing its memory.
+ resetCanvasPreview: function () {
+ this.canvasPreview.width = 0;
+ this.canvasPreview.height = 0;
+ },
+
+ /**
+ * Set the canvas dimensions.
+ */
+ resizeCanvasPreview: function (aRequestedWidth, aRequestedHeight) {
+ this.canvasPreview.width = aRequestedWidth;
+ this.canvasPreview.height = aRequestedHeight;
+ },
+
+
+ get zoom() {
+ // Note that winutils.fullZoom accounts for "quantization" of the zoom factor
+ // from nsIContentViewer due to conversion through appUnits.
+ // We do -not- want screenPixelsPerCSSPixel here, because that would -also-
+ // incorporate any scaling that is applied due to hi-dpi resolution options.
+ return this.tab.linkedBrowser.fullZoom;
+ },
+
+ get screenPixelsPerCSSPixel() {
+ let chromeWin = this.tab.ownerGlobal;
+ let windowUtils = chromeWin.getInterface(Ci.nsIDOMWindowUtils);
+ return windowUtils.screenPixelsPerCSSPixel;
+ },
+
+ get browserDims() {
+ return this.tab.linkedBrowser.getBoundingClientRect();
+ },
+
+ cacheBrowserDims: function () {
+ let dims = this.browserDims;
+ this._cachedWidth = dims.width;
+ this._cachedHeight = dims.height;
+ },
+
+ testCacheBrowserDims: function () {
+ let dims = this.browserDims;
+ return this._cachedWidth == dims.width &&
+ this._cachedHeight == dims.height;
+ },
+
+ /**
+ * Capture a new thumbnail image for this preview. Called by the controller
+ * in response to a request for a new thumbnail image.
+ */
+ updateCanvasPreview: function (aFullScale, aCallback) {
+ // Update our cached browser dims so that delayed resize
+ // events don't trigger another invalidation if this tab becomes active.
+ this.cacheBrowserDims();
+ PageThumbs.captureToCanvas(this.linkedBrowser, this.canvasPreview,
+ aCallback, { fullScale: aFullScale });
+ // If we're updating the canvas, then we're in the middle of a peek so
+ // don't discard the cache of previews.
+ AeroPeek.resetCacheTimer();
+ },
+
+ updateTitleAndTooltip: function () {
+ let title = this.win.tabbrowser.getWindowTitleForBrowser(this.linkedBrowser);
+ this.preview.title = title;
+ this.preview.tooltip = title;
+ },
+
+ // nsITaskbarPreviewController
+
+ // window width and height, not browser
+ get width() {
+ return this.win.width;
+ },
+
+ // window width and height, not browser
+ get height() {
+ return this.win.height;
+ },
+
+ get thumbnailAspectRatio() {
+ let browserDims = this.browserDims;
+ // Avoid returning 0
+ let tabWidth = browserDims.width || 1;
+ // Avoid divide by 0
+ let tabHeight = browserDims.height || 1;
+ return tabWidth / tabHeight;
+ },
+
+ /**
+ * Responds to taskbar requests for window previews. Returns the results asynchronously
+ * through updateCanvasPreview.
+ *
+ * @param aTaskbarCallback nsITaskbarPreviewCallback results callback
+ */
+ requestPreview: function (aTaskbarCallback) {
+ // Grab a high res content preview
+ this.resetCanvasPreview();
+ this.updateCanvasPreview(true, (aPreviewCanvas) => {
+ let winWidth = this.win.width;
+ let winHeight = this.win.height;
+
+ let composite = PageThumbs.createCanvas();
+
+ // Use transparency, Aero glass is drawn black without it.
+ composite.mozOpaque = false;
+
+ let ctx = composite.getContext('2d');
+ let scale = this.screenPixelsPerCSSPixel / this.zoom;
+
+ composite.width = winWidth * scale;
+ composite.height = winHeight * scale;
+
+ ctx.save();
+ ctx.scale(scale, scale);
+
+ // Draw chrome. Note we currently do not get scrollbars for remote frames
+ // in the image above.
+ ctx.drawWindow(this.win.win, 0, 0, winWidth, winHeight, "rgba(0,0,0,0)");
+
+ // Draw the content are into the composite canvas at the right location.
+ ctx.drawImage(aPreviewCanvas, this.browserDims.x, this.browserDims.y,
+ aPreviewCanvas.width, aPreviewCanvas.height);
+ ctx.restore();
+
+ // Deliver the resulting composite canvas to Windows
+ this.win.tabbrowser.previewTab(this.tab, function () {
+ aTaskbarCallback.done(composite, false);
+ });
+ });
+ },
+
+ /**
+ * Responds to taskbar requests for tab thumbnails. Returns the results asynchronously
+ * through updateCanvasPreview.
+ *
+ * Note Windows requests a specific width and height here, if the resulting thumbnail
+ * does not match these dimensions thumbnail display will fail.
+ *
+ * @param aTaskbarCallback nsITaskbarPreviewCallback results callback
+ * @param aRequestedWidth width of the requested thumbnail
+ * @param aRequestedHeight height of the requested thumbnail
+ */
+ requestThumbnail: function (aTaskbarCallback, aRequestedWidth, aRequestedHeight) {
+ this.resizeCanvasPreview(aRequestedWidth, aRequestedHeight);
+ this.updateCanvasPreview(false, (aThumbnailCanvas) => {
+ aTaskbarCallback.done(aThumbnailCanvas, false);
+ });
+ },
+
+ // Event handling
+
+ onClose: function () {
+ this.win.tabbrowser.removeTab(this.tab);
+ },
+
+ onActivate: function () {
+ this.win.tabbrowser.selectedTab = this.tab;
+
+ // Accept activation - this will restore the browser window
+ // if it's minimized
+ return true;
+ },
+
+ // nsIDOMEventListener
+ handleEvent: function (evt) {
+ switch (evt.type) {
+ case "TabAttrModified":
+ this.updateTitleAndTooltip();
+ break;
+ }
+ }
+};
+
+XPCOMUtils.defineLazyGetter(PreviewController.prototype, "canvasPreviewFlags",
+ function () { let canvasInterface = Ci.nsIDOMCanvasRenderingContext2D;
+ return canvasInterface.DRAWWINDOW_DRAW_VIEW
+ | canvasInterface.DRAWWINDOW_DRAW_CARET
+ | canvasInterface.DRAWWINDOW_ASYNC_DECODE_IMAGES
+ | canvasInterface.DRAWWINDOW_DO_NOT_FLUSH;
+});
+
+// TabWindow
+
+/*
+ * This class monitors a browser window for changes to its tabs
+ *
+ * @param win
+ * The nsIDOMWindow browser window
+ */
+function TabWindow(win) {
+ this.win = win;
+ this.tabbrowser = win.gBrowser;
+
+ this.previews = new Map();
+
+ for (let i = 0; i < this.tabEvents.length; i++)
+ this.tabbrowser.tabContainer.addEventListener(this.tabEvents[i], this, false);
+
+ for (let i = 0; i < this.winEvents.length; i++)
+ this.win.addEventListener(this.winEvents[i], this, false);
+
+ this.tabbrowser.addTabsProgressListener(this);
+
+ AeroPeek.windows.push(this);
+ let tabs = this.tabbrowser.tabs;
+ for (let i = 0; i < tabs.length; i++)
+ this.newTab(tabs[i]);
+
+ this.updateTabOrdering();
+ AeroPeek.checkPreviewCount();
+}
+
+TabWindow.prototype = {
+ _enabled: false,
+ _cachedWidth: 0,
+ _cachedHeight: 0,
+ tabEvents: ["TabOpen", "TabClose", "TabSelect", "TabMove"],
+ winEvents: ["resize"],
+
+ destroy: function () {
+ this._destroying = true;
+
+ let tabs = this.tabbrowser.tabs;
+
+ this.tabbrowser.removeTabsProgressListener(this);
+
+ for (let i = 0; i < this.winEvents.length; i++)
+ this.win.removeEventListener(this.winEvents[i], this, false);
+
+ for (let i = 0; i < this.tabEvents.length; i++)
+ this.tabbrowser.tabContainer.removeEventListener(this.tabEvents[i], this, false);
+
+ for (let i = 0; i < tabs.length; i++)
+ this.removeTab(tabs[i]);
+
+ let idx = AeroPeek.windows.indexOf(this.win.gTaskbarTabGroup);
+ AeroPeek.windows.splice(idx, 1);
+ AeroPeek.checkPreviewCount();
+ },
+
+ get width () {
+ return this.win.innerWidth;
+ },
+ get height () {
+ return this.win.innerHeight;
+ },
+
+ cacheDims: function () {
+ this._cachedWidth = this.width;
+ this._cachedHeight = this.height;
+ },
+
+ testCacheDims: function () {
+ return this._cachedWidth == this.width && this._cachedHeight == this.height;
+ },
+
+ // Invoked when the given tab is added to this window
+ newTab: function (tab) {
+ let controller = new PreviewController(this, tab);
+ // It's OK to add the preview now while the favicon still loads.
+ this.previews.set(tab, controller.preview);
+ AeroPeek.addPreview(controller.preview);
+ // updateTitleAndTooltip relies on having controller.preview which is lazily resolved.
+ // Now that we've updated this.previews, it will resolve successfully.
+ controller.updateTitleAndTooltip();
+ },
+
+ createTabPreview: function (controller) {
+ let docShell = this.win
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShell);
+ let preview = AeroPeek.taskbar.createTaskbarTabPreview(docShell, controller);
+ preview.visible = AeroPeek.enabled;
+ preview.active = this.tabbrowser.selectedTab == controller.tab;
+ this.onLinkIconAvailable(controller.tab.linkedBrowser,
+ controller.tab.getAttribute("image"));
+ return preview;
+ },
+
+ // Invoked when the given tab is closed
+ removeTab: function (tab) {
+ let preview = this.previewFromTab(tab);
+ preview.active = false;
+ preview.visible = false;
+ preview.move(null);
+ preview.controller.wrappedJSObject.destroy();
+
+ this.previews.delete(tab);
+ AeroPeek.removePreview(preview);
+ },
+
+ get enabled () {
+ return this._enabled;
+ },
+
+ set enabled (enable) {
+ this._enabled = enable;
+ // Because making a tab visible requires that the tab it is next to be
+ // visible, it is far simpler to unset the 'next' tab and recreate them all
+ // at once.
+ for (let [, preview] of this.previews) {
+ preview.move(null);
+ preview.visible = enable;
+ }
+ this.updateTabOrdering();
+ },
+
+ previewFromTab: function (tab) {
+ return this.previews.get(tab);
+ },
+
+ updateTabOrdering: function () {
+ let previews = this.previews;
+ let tabs = this.tabbrowser.tabs;
+
+ // Previews are internally stored using a map, so we need to iterate the
+ // tabbrowser's array of tabs to retrieve previews in the same order.
+ let inorder = [];
+ for (let t of tabs) {
+ if (previews.has(t)) {
+ inorder.push(previews.get(t));
+ }
+ }
+
+ // Since the internal taskbar array has not yet been updated we must force
+ // on it the sorting order of our local array. To do so we must walk
+ // the local array backwards, otherwise we would send move requests in the
+ // wrong order. See bug 522610 for details.
+ for (let i = inorder.length - 1; i >= 0; i--) {
+ inorder[i].move(inorder[i + 1] || null);
+ }
+ },
+
+ // nsIDOMEventListener
+ handleEvent: function (evt) {
+ let tab = evt.originalTarget;
+ switch (evt.type) {
+ case "TabOpen":
+ this.newTab(tab);
+ this.updateTabOrdering();
+ break;
+ case "TabClose":
+ this.removeTab(tab);
+ this.updateTabOrdering();
+ break;
+ case "TabSelect":
+ this.previewFromTab(tab).active = true;
+ break;
+ case "TabMove":
+ this.updateTabOrdering();
+ break;
+ case "resize":
+ if (!AeroPeek._prefenabled)
+ return;
+ this.onResize();
+ break;
+ }
+ },
+
+ // Set or reset a timer that will invalidate visible thumbnails soon.
+ setInvalidationTimer: function () {
+ if (!this.invalidateTimer) {
+ this.invalidateTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ }
+ this.invalidateTimer.cancel();
+
+ // delay 1 second before invalidating
+ this.invalidateTimer.initWithCallback(() => {
+ // invalidate every preview. note the internal implementation of
+ // invalidate ignores thumbnails that aren't visible.
+ this.previews.forEach(function (aPreview) {
+ let controller = aPreview.controller.wrappedJSObject;
+ if (!controller.testCacheBrowserDims()) {
+ controller.cacheBrowserDims();
+ aPreview.invalidate();
+ }
+ });
+ }, 1000, Ci.nsITimer.TYPE_ONE_SHOT);
+ },
+
+ onResize: function () {
+ // Specific to a window.
+
+ // Call invalidate on each tab thumbnail so that Windows will request an
+ // updated image. However don't do this repeatedly across multiple resize
+ // events triggered during window border drags.
+
+ if (this.testCacheDims()) {
+ return;
+ }
+
+ // update the window dims on our TabWindow object.
+ this.cacheDims();
+
+ // invalidate soon
+ this.setInvalidationTimer();
+ },
+
+ invalidateTabPreview: function(aBrowser) {
+ for (let [tab, preview] of this.previews) {
+ if (aBrowser == tab.linkedBrowser) {
+ preview.invalidate();
+ break;
+ }
+ }
+ },
+
+ // Browser progress listener
+
+ onLocationChange: function (aBrowser) {
+ // I'm not sure we need this, onStateChange does a really good job
+ // of picking up page changes.
+ // this.invalidateTabPreview(aBrowser);
+ },
+
+ onStateChange: function (aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK) {
+ this.invalidateTabPreview(aBrowser);
+ }
+ },
+
+ directRequestProtocols: new Set([
+ "file", "chrome", "resource", "about"
+ ]),
+ onLinkIconAvailable: function (aBrowser, aIconURL) {
+ let requestURL = null;
+ if (aIconURL) {
+ let shouldRequestFaviconURL = true;
+ try {
+ let urlObject = NetUtil.newURI(aIconURL);
+ shouldRequestFaviconURL =
+ !this.directRequestProtocols.has(urlObject.scheme);
+ } catch (ex) {}
+
+ requestURL = shouldRequestFaviconURL ?
+ "moz-anno:favicon:" + aIconURL :
+ aIconURL;
+ }
+ let isDefaultFavicon = !requestURL;
+ getFaviconAsImage(
+ requestURL,
+ PrivateBrowsingUtils.isWindowPrivate(this.win),
+ img => {
+ let index = this.tabbrowser.browsers.indexOf(aBrowser);
+ // Only add it if we've found the index and the URI is still the same.
+ // The tab could have closed, and there's no guarantee the icons
+ // will have finished fetching 'in order'.
+ if (index != -1) {
+ let tab = this.tabbrowser.tabs[index];
+ let preview = this.previews.get(tab);
+ if (tab.getAttribute("image") == aIconURL ||
+ (!preview.icon && isDefaultFavicon)) {
+ preview.icon = img;
+ }
+ }
+ }
+ );
+ }
+}
+
+// AeroPeek
+
+/*
+ * This object acts as global storage and external interface for this feature.
+ * It maintains the values of the prefs.
+ */
+this.AeroPeek = {
+ available: false,
+ // Does the pref say we're enabled?
+ __prefenabled: false,
+
+ _enabled: true,
+
+ initialized: false,
+
+ // nsITaskbarTabPreview array
+ previews: [],
+
+ // TabWindow array
+ windows: [],
+
+ // nsIWinTaskbar service
+ taskbar: null,
+
+ // Maximum number of previews
+ maxpreviews: 20,
+
+ // Length of time in seconds that previews are cached
+ cacheLifespan: 20,
+
+ initialize: function () {
+ if (!(WINTASKBAR_CONTRACTID in Cc))
+ return;
+ this.taskbar = Cc[WINTASKBAR_CONTRACTID].getService(Ci.nsIWinTaskbar);
+ this.available = this.taskbar.available;
+ if (!this.available)
+ return;
+
+ this.prefs.addObserver(TOGGLE_PREF_NAME, this, true);
+ this.enabled = this._prefenabled = this.prefs.getBoolPref(TOGGLE_PREF_NAME);
+ this.initialized = true;
+ },
+
+ destroy: function destroy() {
+ this._enabled = false;
+
+ if (this.cacheTimer)
+ this.cacheTimer.cancel();
+ },
+
+ get enabled() {
+ return this._enabled;
+ },
+
+ set enabled(enable) {
+ if (this._enabled == enable)
+ return;
+
+ this._enabled = enable;
+
+ this.windows.forEach(function (win) {
+ win.enabled = enable;
+ });
+ },
+
+ get _prefenabled() {
+ return this.__prefenabled;
+ },
+
+ set _prefenabled(enable) {
+ if (enable == this.__prefenabled) {
+ return;
+ }
+ this.__prefenabled = enable;
+
+ if (enable) {
+ this.enable();
+ } else {
+ this.disable();
+ }
+ },
+
+ _observersAdded: false,
+
+ enable() {
+ if (!this._observersAdded) {
+ this.prefs.addObserver(DISABLE_THRESHOLD_PREF_NAME, this, true);
+ this.prefs.addObserver(CACHE_EXPIRATION_TIME_PREF_NAME, this, true);
+ PlacesUtils.history.addObserver(this, true);
+ this._observersAdded = true;
+ }
+
+ this.cacheLifespan = this.prefs.getIntPref(CACHE_EXPIRATION_TIME_PREF_NAME);
+
+ this.maxpreviews = this.prefs.getIntPref(DISABLE_THRESHOLD_PREF_NAME);
+
+ // If the user toggled us on/off while the browser was already up
+ // (rather than this code running on startup because the pref was
+ // already set to true), we must initialize previews for open windows:
+ if (this.initialized) {
+ let browserWindows = Services.wm.getEnumerator("navigator:browser");
+ while (browserWindows.hasMoreElements()) {
+ let win = browserWindows.getNext();
+ if (!win.closed) {
+ this.onOpenWindow(win);
+ }
+ }
+ }
+ },
+
+ disable() {
+ while (this.windows.length) {
+ // We can't call onCloseWindow here because it'll bail if we're not
+ // enabled.
+ let tabWinObject = this.windows[0];
+ tabWinObject.destroy(); // This will remove us from the array.
+ delete tabWinObject.win.gTaskbarTabGroup; // Tidy up the window.
+ }
+ },
+
+ addPreview: function (preview) {
+ this.previews.push(preview);
+ this.checkPreviewCount();
+ },
+
+ removePreview: function (preview) {
+ let idx = this.previews.indexOf(preview);
+ this.previews.splice(idx, 1);
+ this.checkPreviewCount();
+ },
+
+ checkPreviewCount: function () {
+ if (!this._prefenabled) {
+ return;
+ }
+ this.enabled = this.previews.length <= this.maxpreviews;
+ },
+
+ onOpenWindow: function (win) {
+ // This occurs when the taskbar service is not available (xp, vista)
+ if (!this.available || !this._prefenabled)
+ return;
+
+ win.gTaskbarTabGroup = new TabWindow(win);
+ },
+
+ onCloseWindow: function (win) {
+ // This occurs when the taskbar service is not available (xp, vista)
+ if (!this.available || !this._prefenabled)
+ return;
+
+ win.gTaskbarTabGroup.destroy();
+ delete win.gTaskbarTabGroup;
+
+ if (this.windows.length == 0)
+ this.destroy();
+ },
+
+ resetCacheTimer: function () {
+ this.cacheTimer.cancel();
+ this.cacheTimer.init(this, 1000*this.cacheLifespan, Ci.nsITimer.TYPE_ONE_SHOT);
+ },
+
+ // nsIObserver
+ observe: function (aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed" && aData == TOGGLE_PREF_NAME) {
+ this._prefenabled = this.prefs.getBoolPref(TOGGLE_PREF_NAME);
+ }
+ if (!this._prefenabled) {
+ return;
+ }
+ switch (aTopic) {
+ case "nsPref:changed":
+ if (aData == CACHE_EXPIRATION_TIME_PREF_NAME)
+ break;
+
+ if (aData == DISABLE_THRESHOLD_PREF_NAME)
+ this.maxpreviews = this.prefs.getIntPref(DISABLE_THRESHOLD_PREF_NAME);
+ // Might need to enable/disable ourselves
+ this.checkPreviewCount();
+ break;
+ case "timer-callback":
+ this.previews.forEach(function (preview) {
+ let controller = preview.controller.wrappedJSObject;
+ controller.resetCanvasPreview();
+ });
+ break;
+ }
+ },
+
+ /* nsINavHistoryObserver implementation */
+ onBeginUpdateBatch() {},
+ onEndUpdateBatch() {},
+ onVisit() {},
+ onTitleChanged() {},
+ onFrecencyChanged() {},
+ onManyFrecenciesChanged() {},
+ onDeleteURI() {},
+ onClearHistory() {},
+ onDeleteVisits() {},
+ onPageChanged(uri, changedConst, newValue) {
+ if (this.enabled && changedConst == Ci.nsINavHistoryObserver.ATTRIBUTE_FAVICON) {
+ for (let win of this.windows) {
+ for (let [tab, ] of win.previews) {
+ if (tab.getAttribute("image") == newValue) {
+ win.onLinkIconAvailable(tab.linkedBrowser, newValue);
+ }
+ }
+ }
+ }
+ },
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsISupportsWeakReference,
+ Ci.nsINavHistoryObserver,
+ Ci.nsIObserver
+ ]),
+};
+
+XPCOMUtils.defineLazyGetter(AeroPeek, "cacheTimer", () =>
+ Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer)
+);
+
+XPCOMUtils.defineLazyServiceGetter(AeroPeek, "prefs",
+ "@mozilla.org/preferences-service;1",
+ "nsIPrefBranch");
+
+AeroPeek.initialize();
diff --git a/browser/modules/moz.build b/browser/modules/moz.build
new file mode 100644
index 000000000..d71305198
--- /dev/null
+++ b/browser/modules/moz.build
@@ -0,0 +1,56 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
+XPCSHELL_TESTS_MANIFESTS += [
+ 'test/unit/social/xpcshell.ini',
+ 'test/xpcshell/xpcshell.ini',
+]
+
+EXTRA_JS_MODULES += [
+ 'AboutHome.jsm',
+ 'AboutNewTab.jsm',
+ 'AttributionCode.jsm',
+ 'BrowserUITelemetry.jsm',
+ 'BrowserUsageTelemetry.jsm',
+ 'CastingApps.jsm',
+ 'ContentClick.jsm',
+ 'ContentCrashHandlers.jsm',
+ 'ContentLinkHandler.jsm',
+ 'ContentObservers.jsm',
+ 'ContentSearch.jsm',
+ 'ContentWebRTC.jsm',
+ 'DirectoryLinksProvider.jsm',
+ 'E10SUtils.jsm',
+ 'Feeds.jsm',
+ 'FormSubmitObserver.jsm',
+ 'FormValidationHandler.jsm',
+ 'HiddenFrame.jsm',
+ 'LaterRun.jsm',
+ 'NetworkPrioritizer.jsm',
+ 'offlineAppCache.jsm',
+ 'PermissionUI.jsm',
+ 'PluginContent.jsm',
+ 'ProcessHangMonitor.jsm',
+ 'ReaderParent.jsm',
+ 'RecentWindow.jsm',
+ 'RemotePrompt.jsm',
+ 'Sanitizer.jsm',
+ 'SelfSupportBackend.jsm',
+ 'SitePermissions.jsm',
+ 'Social.jsm',
+ 'SocialService.jsm',
+ 'TransientPrefs.jsm',
+ 'URLBarZoom.jsm',
+ 'webrtcUI.jsm',
+]
+
+if CONFIG['MOZ_WIDGET_TOOLKIT'] == 'windows':
+ EXTRA_JS_MODULES += [
+ 'Windows8WindowFrameColor.jsm',
+ 'WindowsJumpLists.jsm',
+ 'WindowsPreviewPerTab.jsm',
+ ]
diff --git a/browser/modules/offlineAppCache.jsm b/browser/modules/offlineAppCache.jsm
new file mode 100644
index 000000000..5d0e3481a
--- /dev/null
+++ b/browser/modules/offlineAppCache.jsm
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.EXPORTED_SYMBOLS = ["OfflineAppCacheHelper"];
+
+Components.utils.import('resource://gre/modules/LoadContextInfo.jsm');
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+
+this.OfflineAppCacheHelper = {
+ clear: function() {
+ var cacheService = Cc["@mozilla.org/netwerk/cache-storage-service;1"].getService(Ci.nsICacheStorageService);
+ var appCacheStorage = cacheService.appCacheStorage(LoadContextInfo.default, null);
+ try {
+ appCacheStorage.asyncEvictStorage(null);
+ } catch (er) {}
+ }
+};
diff --git a/browser/modules/test/.eslintrc.js b/browser/modules/test/.eslintrc.js
new file mode 100644
index 000000000..e2d7896f8
--- /dev/null
+++ b/browser/modules/test/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../testing/mochitest/browser.eslintrc.js"
+ ]
+};
diff --git a/browser/modules/test/browser.ini b/browser/modules/test/browser.ini
new file mode 100644
index 000000000..af624439c
--- /dev/null
+++ b/browser/modules/test/browser.ini
@@ -0,0 +1,42 @@
+[DEFAULT]
+support-files =
+ head.js
+
+[browser_BrowserUITelemetry_buckets.js]
+[browser_BrowserUITelemetry_defaults.js]
+[browser_BrowserUITelemetry_sidebar.js]
+[browser_BrowserUITelemetry_syncedtabs.js]
+[browser_ContentSearch.js]
+skip-if = true # Bug 1308343
+support-files =
+ contentSearch.js
+ contentSearchBadImage.xml
+ contentSearchSuggestions.sjs
+ contentSearchSuggestions.xml
+ !/browser/components/search/test/head.js
+ !/browser/components/search/test/testEngine.xml
+[browser_NetworkPrioritizer.js]
+[browser_PermissionUI.js]
+[browser_ProcessHangNotifications.js]
+skip-if = !e10s
+[browser_SelfSupportBackend.js]
+support-files =
+ ../../components/uitour/test/uitour.html
+ ../../components/uitour/UITour-lib.js
+[browser_taskbar_preview.js]
+skip-if = os != "win"
+[browser_UnsubmittedCrashHandler.js]
+run-if = crashreporter
+[browser_UsageTelemetry.js]
+[browser_UsageTelemetry_private_and_restore.js]
+[browser_UsageTelemetry_urlbar.js]
+support-files =
+ usageTelemetrySearchSuggestions.sjs
+ usageTelemetrySearchSuggestions.xml
+[browser_UsageTelemetry_searchbar.js]
+support-files =
+ usageTelemetrySearchSuggestions.sjs
+ usageTelemetrySearchSuggestions.xml
+[browser_UsageTelemetry_content.js]
+[browser_UsageTelemetry_content_aboutHome.js]
+[browser_urlBar_zoom.js]
diff --git a/browser/modules/test/browser_BrowserUITelemetry_buckets.js b/browser/modules/test/browser_BrowserUITelemetry_buckets.js
new file mode 100644
index 000000000..f55761705
--- /dev/null
+++ b/browser/modules/test/browser_BrowserUITelemetry_buckets.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ WHERE'S MAH BUCKET?!
+ \
+ ___
+ .-9 9 `\
+ =(:(::)= ;
+ |||| \
+ |||| `-.
+ ,\|\| `,
+ / \
+ ; `'---.,
+ | `\
+ ; / |
+ \ | /
+ ) \ __,.--\ /
+ .-' \,..._\ \` .-' .-'
+ `-=`` `: | /-/-/`
+ `.__/
+*/
+
+"use strict";
+
+
+add_task(function* testBUIT() {
+ let s = {};
+ Components.utils.import("resource:///modules/BrowserUITelemetry.jsm", s);
+ let BUIT = s.BrowserUITelemetry;
+
+ registerCleanupFunction(function() {
+ BUIT.setBucket(null);
+ });
+
+
+ // setBucket
+ is(BUIT.currentBucket, BUIT.BUCKET_DEFAULT, "Bucket should be default bucket");
+ BUIT.setBucket("mah-bucket");
+ is(BUIT.currentBucket, BUIT.BUCKET_PREFIX + "mah-bucket", "Bucket should have correct name");
+ BUIT.setBucket(null);
+ is(BUIT.currentBucket, BUIT.BUCKET_DEFAULT, "Bucket should be reset to default");
+
+
+ // _toTimeStr
+ is(BUIT._toTimeStr(10), "10ms", "Checking time string reprentation, 10ms");
+ is(BUIT._toTimeStr(1000 + 10), "1s10ms", "Checking time string reprentation, 1s10ms");
+ is(BUIT._toTimeStr((20 * 1000) + 10), "20s10ms", "Checking time string reprentation, 20s10ms");
+ is(BUIT._toTimeStr(60 * 1000), "1m", "Checking time string reprentation, 1m");
+ is(BUIT._toTimeStr(3 * 60 * 1000), "3m", "Checking time string reprentation, 3m");
+ is(BUIT._toTimeStr((3 * 60 * 1000) + 1), "3m1ms", "Checking time string reprentation, 3m1ms");
+ is(BUIT._toTimeStr((60 * 60 * 1000) + (10 * 60 * 1000)), "1h10m", "Checking time string reprentation, 1h10m");
+ is(BUIT._toTimeStr(100 * 60 * 60 * 1000), "100h", "Checking time string reprentation, 100h");
+
+
+ // setExpiringBucket
+ BUIT.setExpiringBucket("walrus", [1001, 2001, 3001, 10001]);
+ is(BUIT.currentBucket, BUIT.BUCKET_PREFIX + "walrus" + BUIT.BUCKET_SEPARATOR + "1s1ms",
+ "Bucket should be expiring and have time step of 1s1ms");
+
+ yield waitForConditionPromise(function() {
+ return BUIT.currentBucket == (BUIT.BUCKET_PREFIX + "walrus" + BUIT.BUCKET_SEPARATOR + "2s1ms");
+ }, "Bucket should be expiring and have time step of 2s1ms");
+
+ yield waitForConditionPromise(function() {
+ return BUIT.currentBucket == (BUIT.BUCKET_PREFIX + "walrus" + BUIT.BUCKET_SEPARATOR + "3s1ms");
+ }, "Bucket should be expiring and have time step of 3s1ms");
+
+
+ // Interupt previous expiring bucket
+ BUIT.setExpiringBucket("walrus2", [1002, 2002]);
+ is(BUIT.currentBucket, BUIT.BUCKET_PREFIX + "walrus2" + BUIT.BUCKET_SEPARATOR + "1s2ms",
+ "Should be new expiring bucket, with time step of 1s2ms");
+
+ yield waitForConditionPromise(function() {
+ return BUIT.currentBucket == (BUIT.BUCKET_PREFIX + "walrus2" + BUIT.BUCKET_SEPARATOR + "2s2ms");
+ }, "Should be new expiring bucket, with time step of 2s2ms");
+
+
+ // Let expiring bucket expire
+ yield waitForConditionPromise(function() {
+ return BUIT.currentBucket == BUIT.BUCKET_DEFAULT;
+ }, "Bucket should have expired, default bucket should now be active");
+
+
+ // Interupt expiring bucket with normal bucket
+ BUIT.setExpiringBucket("walrus3", [1003, 2003]);
+ is(BUIT.currentBucket, BUIT.BUCKET_PREFIX + "walrus3" + BUIT.BUCKET_SEPARATOR + "1s3ms",
+ "Should be new expiring bucket, with time step of 1s3ms");
+
+ BUIT.setBucket("mah-bucket");
+ is(BUIT.currentBucket, BUIT.BUCKET_PREFIX + "mah-bucket", "Bucket should have correct name");
+
+ yield waitForConditionPromise(function() {
+ return BUIT.currentBucket == (BUIT.BUCKET_PREFIX + "mah-bucket");
+ }, "Next step of old expiring bucket shouldn't have progressed");
+});
diff --git a/browser/modules/test/browser_BrowserUITelemetry_defaults.js b/browser/modules/test/browser_BrowserUITelemetry_defaults.js
new file mode 100644
index 000000000..ced1bbce0
--- /dev/null
+++ b/browser/modules/test/browser_BrowserUITelemetry_defaults.js
@@ -0,0 +1,37 @@
+// The purpose of this test is to ensure that by default, BrowserUITelemetry
+// isn't reporting any UI customizations. This is primarily so changes to
+// customizableUI (eg, new buttons, button location changes) also have a
+// corresponding BrowserUITelemetry change.
+
+function test() {
+ let s = {};
+ Cu.import("resource:///modules/CustomizableUI.jsm", s);
+ Cu.import("resource:///modules/BrowserUITelemetry.jsm", s);
+
+ let { CustomizableUI, BrowserUITelemetry } = s;
+
+ // Bug 1278176 - DevEdition never has the UI in a default state by default.
+ if (!AppConstants.MOZ_DEV_EDITION) {
+ Assert.ok(CustomizableUI.inDefaultState,
+ "No other test should have left CUI in a dirty state.");
+ }
+
+ let result = BrowserUITelemetry._getWindowMeasurements(window, 0);
+
+ // Bug 1278176 - DevEdition always reports the developer-button is moved.
+ if (!AppConstants.MOZ_DEV_EDITION) {
+ Assert.deepEqual(result.defaultMoved, []);
+ }
+ Assert.deepEqual(result.nondefaultAdded, []);
+ // This one is a bit weird - the "social-share-button" is dynamically added
+ // to the toolbar as the feature is first used - but it's listed as being in
+ // the toolbar by default so it doesn't end up in nondefaultAdded once it
+ // is created. The end result is that it ends up in defaultRemoved before
+ // the feature has been activated.
+ // Bug 1273358 exists to fix this.
+ Assert.deepEqual(result.defaultRemoved, ["social-share-button"]);
+
+ // And mochi insists there's only a single window with a single tab when
+ // starting a test, so check that for good measure.
+ Assert.deepEqual(result.visibleTabs, [1]);
+}
diff --git a/browser/modules/test/browser_BrowserUITelemetry_sidebar.js b/browser/modules/test/browser_BrowserUITelemetry_sidebar.js
new file mode 100644
index 000000000..5f19eabd5
--- /dev/null
+++ b/browser/modules/test/browser_BrowserUITelemetry_sidebar.js
@@ -0,0 +1,56 @@
+// Test the sidebar counters in BrowserUITelemetry.
+"use strict";
+
+const { BrowserUITelemetry: BUIT } = Cu.import("resource:///modules/BrowserUITelemetry.jsm", {});
+
+add_task(function* testSidebarOpenClose() {
+ // Reset BrowserUITelemetry's world.
+ BUIT._countableEvents = {};
+
+ yield SidebarUI.show("viewTabsSidebar");
+
+ let counts = BUIT._countableEvents[BUIT.currentBucket];
+
+ Assert.deepEqual(counts, { sidebar: { viewTabsSidebar: { show: 1 } } });
+ yield SidebarUI.hide();
+ Assert.deepEqual(counts, { sidebar: { viewTabsSidebar: { show: 1, hide: 1 } } });
+
+ yield SidebarUI.show("viewBookmarksSidebar");
+ Assert.deepEqual(counts, {
+ sidebar: {
+ viewTabsSidebar: { show: 1, hide: 1 },
+ viewBookmarksSidebar: { show: 1 },
+ }
+ });
+ // Re-open the tabs sidebar while bookmarks is open - bookmarks should
+ // record a close.
+ yield SidebarUI.show("viewTabsSidebar");
+ Assert.deepEqual(counts, {
+ sidebar: {
+ viewTabsSidebar: { show: 2, hide: 1 },
+ viewBookmarksSidebar: { show: 1, hide: 1 },
+ }
+ });
+ yield SidebarUI.hide();
+ Assert.deepEqual(counts, {
+ sidebar: {
+ viewTabsSidebar: { show: 2, hide: 2 },
+ viewBookmarksSidebar: { show: 1, hide: 1 },
+ }
+ });
+ // Toggle - this will re-open viewTabsSidebar
+ yield SidebarUI.toggle("viewTabsSidebar");
+ Assert.deepEqual(counts, {
+ sidebar: {
+ viewTabsSidebar: { show: 3, hide: 2 },
+ viewBookmarksSidebar: { show: 1, hide: 1 },
+ }
+ });
+ yield SidebarUI.toggle("viewTabsSidebar");
+ Assert.deepEqual(counts, {
+ sidebar: {
+ viewTabsSidebar: { show: 3, hide: 3 },
+ viewBookmarksSidebar: { show: 1, hide: 1 },
+ }
+ });
+});
diff --git a/browser/modules/test/browser_BrowserUITelemetry_syncedtabs.js b/browser/modules/test/browser_BrowserUITelemetry_syncedtabs.js
new file mode 100644
index 000000000..d3e1eac57
--- /dev/null
+++ b/browser/modules/test/browser_BrowserUITelemetry_syncedtabs.js
@@ -0,0 +1,114 @@
+// Test the SyncedTabs counters in BrowserUITelemetry.
+"use strict";
+
+const { BrowserUITelemetry: BUIT } = Cu.import("resource:///modules/BrowserUITelemetry.jsm", {});
+const {SyncedTabs} = Cu.import("resource://services-sync/SyncedTabs.jsm", {});
+
+function mockSyncedTabs() {
+ // Mock SyncedTabs.jsm
+ let mockedInternal = {
+ get isConfiguredToSyncTabs() { return true; },
+ getTabClients() {
+ return Promise.resolve([
+ {
+ id: "guid_desktop",
+ type: "client",
+ name: "My Desktop",
+ tabs: [
+ {
+ title: "http://example.com/10",
+ lastUsed: 10, // the most recent
+ },
+ ],
+ }
+ ]);
+ },
+ syncTabs() {
+ return Promise.resolve();
+ },
+ hasSyncedThisSession: true,
+ };
+
+ let oldInternal = SyncedTabs._internal;
+ SyncedTabs._internal = mockedInternal;
+
+ // configure our broadcasters so we are in the right state.
+ document.getElementById("sync-reauth-state").hidden = true;
+ document.getElementById("sync-setup-state").hidden = true;
+ document.getElementById("sync-syncnow-state").hidden = false;
+
+ registerCleanupFunction(() => {
+ SyncedTabs._internal = oldInternal;
+
+ document.getElementById("sync-reauth-state").hidden = true;
+ document.getElementById("sync-setup-state").hidden = false;
+ document.getElementById("sync-syncnow-state").hidden = true;
+ });
+}
+
+mockSyncedTabs();
+
+function promiseTabsUpdated() {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function onNotification(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(onNotification, aTopic);
+ resolve();
+ }, "synced-tabs-menu:test:tabs-updated", false);
+ });
+}
+
+add_task(function* test_menu() {
+ // Reset BrowserUITelemetry's world.
+ BUIT._countableEvents = {};
+
+ let tabsUpdated = promiseTabsUpdated();
+
+ // check the button's functionality
+ yield PanelUI.show();
+
+ let syncButton = document.getElementById("sync-button");
+ syncButton.click();
+
+ yield tabsUpdated;
+ // Get our 1 tab and click on it.
+ let tabList = document.getElementById("PanelUI-remotetabs-tabslist");
+ let tabEntry = tabList.firstChild.nextSibling;
+ tabEntry.click();
+
+ let counts = BUIT._countableEvents[BUIT.currentBucket];
+ Assert.deepEqual(counts, {
+ "click-builtin-item": { "sync-button": { left: 1 } },
+ "synced-tabs": { open: { "toolbarbutton-subview": 1 } },
+ });
+});
+
+add_task(function* test_sidebar() {
+ // Reset BrowserUITelemetry's world.
+ BUIT._countableEvents = {};
+
+ yield SidebarUI.show('viewTabsSidebar');
+
+ let syncedTabsDeckComponent = SidebarUI.browser.contentWindow.syncedTabsDeckComponent;
+
+ syncedTabsDeckComponent._accountStatus = () => Promise.resolve(true);
+
+ // Once the tabs container has been selected (which here means "'selected'
+ // added to the class list") we are ready to test.
+ let container = SidebarUI.browser.contentDocument.querySelector(".tabs-container");
+ let promiseUpdated = BrowserTestUtils.waitForAttribute("class", container);
+
+ yield syncedTabsDeckComponent.updatePanel();
+ yield promiseUpdated;
+
+ let selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected");
+ let tab = selectedPanel.querySelector(".tab");
+ tab.click();
+ let counts = BUIT._countableEvents[BUIT.currentBucket];
+ Assert.deepEqual(counts, {
+ sidebar: {
+ viewTabsSidebar: { show: 1 },
+ },
+ "synced-tabs": { open: { sidebar: 1 } }
+ });
+ yield SidebarUI.hide();
+});
diff --git a/browser/modules/test/browser_ContentSearch.js b/browser/modules/test/browser_ContentSearch.js
new file mode 100644
index 000000000..97bd6ac51
--- /dev/null
+++ b/browser/modules/test/browser_ContentSearch.js
@@ -0,0 +1,425 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TEST_MSG = "ContentSearchTest";
+const CONTENT_SEARCH_MSG = "ContentSearch";
+const TEST_CONTENT_SCRIPT_BASENAME = "contentSearch.js";
+
+// This timeout is absurdly high to avoid random failures like bug 1087120.
+// That bug was reported when the timeout was 5 seconds, so let's try 10.
+const SUGGESTIONS_TIMEOUT = 10000;
+
+var gMsgMan;
+/* eslint no-undef:"error" */
+/* import-globals-from ../../components/search/test/head.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/components/search/test/head.js",
+ this);
+
+let originalEngine = Services.search.currentEngine;
+
+add_task(function* setup() {
+ yield promiseNewEngine("testEngine.xml", {
+ setAsCurrent: true,
+ testPath: "chrome://mochitests/content/browser/browser/components/search/test/",
+ });
+
+ registerCleanupFunction(() => {
+ Services.search.currentEngine = originalEngine;
+ });
+});
+
+add_task(function* GetState() {
+ yield addTab();
+ gMsgMan.sendAsyncMessage(TEST_MSG, {
+ type: "GetState",
+ });
+ let msg = yield waitForTestMsg("State");
+ checkMsg(msg, {
+ type: "State",
+ data: yield currentStateObj(),
+ });
+});
+
+add_task(function* SetCurrentEngine() {
+ yield addTab();
+ let newCurrentEngine = null;
+ let oldCurrentEngine = Services.search.currentEngine;
+ let engines = Services.search.getVisibleEngines();
+ for (let engine of engines) {
+ if (engine != oldCurrentEngine) {
+ newCurrentEngine = engine;
+ break;
+ }
+ }
+ if (!newCurrentEngine) {
+ info("Couldn't find a non-selected search engine, " +
+ "skipping this part of the test");
+ return;
+ }
+ gMsgMan.sendAsyncMessage(TEST_MSG, {
+ type: "SetCurrentEngine",
+ data: newCurrentEngine.name,
+ });
+ let deferred = Promise.defer();
+ Services.obs.addObserver(function obs(subj, topic, data) {
+ info("Test observed " + data);
+ if (data == "engine-current") {
+ ok(true, "Test observed engine-current");
+ Services.obs.removeObserver(obs, "browser-search-engine-modified");
+ deferred.resolve();
+ }
+ }, "browser-search-engine-modified", false);
+ let searchPromise = waitForTestMsg("CurrentEngine");
+ info("Waiting for test to observe engine-current...");
+ yield deferred.promise;
+ let msg = yield searchPromise;
+ checkMsg(msg, {
+ type: "CurrentEngine",
+ data: yield currentEngineObj(newCurrentEngine),
+ });
+
+ Services.search.currentEngine = oldCurrentEngine;
+ msg = yield waitForTestMsg("CurrentEngine");
+ checkMsg(msg, {
+ type: "CurrentEngine",
+ data: yield currentEngineObj(oldCurrentEngine),
+ });
+});
+
+add_task(function* modifyEngine() {
+ yield addTab();
+ let engine = Services.search.currentEngine;
+ let oldAlias = engine.alias;
+ engine.alias = "ContentSearchTest";
+ let msg = yield waitForTestMsg("CurrentState");
+ checkMsg(msg, {
+ type: "CurrentState",
+ data: yield currentStateObj(),
+ });
+ engine.alias = oldAlias;
+ msg = yield waitForTestMsg("CurrentState");
+ checkMsg(msg, {
+ type: "CurrentState",
+ data: yield currentStateObj(),
+ });
+});
+
+add_task(function* search() {
+ yield addTab();
+ let engine = Services.search.currentEngine;
+ let data = {
+ engineName: engine.name,
+ searchString: "ContentSearchTest",
+ healthReportKey: "ContentSearchTest",
+ searchPurpose: "ContentSearchTest",
+ };
+ let submissionURL =
+ engine.getSubmission(data.searchString, "", data.whence).uri.spec;
+ gMsgMan.sendAsyncMessage(TEST_MSG, {
+ type: "Search",
+ data: data,
+ expectedURL: submissionURL,
+ });
+ let msg = yield waitForTestMsg("loadStopped");
+ Assert.equal(msg.data.url, submissionURL, "Correct search page loaded");
+});
+
+add_task(function* searchInBackgroundTab() {
+ // This test is like search(), but it opens a new tab after starting a search
+ // in another. In other words, it performs a search in a background tab. The
+ // search page should be loaded in the same tab that performed the search, in
+ // the background tab.
+ yield addTab();
+ let engine = Services.search.currentEngine;
+ let data = {
+ engineName: engine.name,
+ searchString: "ContentSearchTest",
+ healthReportKey: "ContentSearchTest",
+ searchPurpose: "ContentSearchTest",
+ };
+ let submissionURL =
+ engine.getSubmission(data.searchString, "", data.whence).uri.spec;
+ gMsgMan.sendAsyncMessage(TEST_MSG, {
+ type: "Search",
+ data: data,
+ expectedURL: submissionURL,
+ });
+
+ let newTab = gBrowser.addTab();
+ gBrowser.selectedTab = newTab;
+ registerCleanupFunction(() => gBrowser.removeTab(newTab));
+
+ let msg = yield waitForTestMsg("loadStopped");
+ Assert.equal(msg.data.url, submissionURL, "Correct search page loaded");
+});
+
+add_task(function* badImage() {
+ yield addTab();
+ // If the bad image URI caused an exception to be thrown within ContentSearch,
+ // then we'll hang waiting for the CurrentState responses triggered by the new
+ // engine. That's what we're testing, and obviously it shouldn't happen.
+ let vals = yield waitForNewEngine("contentSearchBadImage.xml", 1);
+ let engine = vals[0];
+ let finalCurrentStateMsg = vals[vals.length - 1];
+ let expectedCurrentState = yield currentStateObj();
+ let expectedEngine =
+ expectedCurrentState.engines.find(e => e.name == engine.name);
+ ok(!!expectedEngine, "Sanity check: engine should be in expected state");
+ ok(expectedEngine.iconBuffer === null,
+ "Sanity check: icon array buffer of engine in expected state " +
+ "should be null: " + expectedEngine.iconBuffer);
+ checkMsg(finalCurrentStateMsg, {
+ type: "CurrentState",
+ data: expectedCurrentState,
+ });
+ // Removing the engine triggers a final CurrentState message. Wait for it so
+ // it doesn't trip up subsequent tests.
+ Services.search.removeEngine(engine);
+ yield waitForTestMsg("CurrentState");
+});
+
+add_task(function* GetSuggestions_AddFormHistoryEntry_RemoveFormHistoryEntry() {
+ yield addTab();
+
+ // Add the test engine that provides suggestions.
+ let vals = yield waitForNewEngine("contentSearchSuggestions.xml", 0);
+ let engine = vals[0];
+
+ let searchStr = "browser_ContentSearch.js-suggestions-";
+
+ // Add a form history suggestion and wait for Satchel to notify about it.
+ gMsgMan.sendAsyncMessage(TEST_MSG, {
+ type: "AddFormHistoryEntry",
+ data: searchStr + "form",
+ });
+ let deferred = Promise.defer();
+ Services.obs.addObserver(function onAdd(subj, topic, data) {
+ if (data == "formhistory-add") {
+ Services.obs.removeObserver(onAdd, "satchel-storage-changed");
+ executeSoon(() => deferred.resolve());
+ }
+ }, "satchel-storage-changed", false);
+ yield deferred.promise;
+
+ // Send GetSuggestions using the test engine. Its suggestions should appear
+ // in the remote suggestions in the Suggestions response below.
+ gMsgMan.sendAsyncMessage(TEST_MSG, {
+ type: "GetSuggestions",
+ data: {
+ engineName: engine.name,
+ searchString: searchStr,
+ remoteTimeout: SUGGESTIONS_TIMEOUT,
+ },
+ });
+
+ // Check the Suggestions response.
+ let msg = yield waitForTestMsg("Suggestions");
+ checkMsg(msg, {
+ type: "Suggestions",
+ data: {
+ engineName: engine.name,
+ searchString: searchStr,
+ formHistory: [searchStr + "form"],
+ remote: [searchStr + "foo", searchStr + "bar"],
+ },
+ });
+
+ // Delete the form history suggestion and wait for Satchel to notify about it.
+ gMsgMan.sendAsyncMessage(TEST_MSG, {
+ type: "RemoveFormHistoryEntry",
+ data: searchStr + "form",
+ });
+ deferred = Promise.defer();
+ Services.obs.addObserver(function onRemove(subj, topic, data) {
+ if (data == "formhistory-remove") {
+ Services.obs.removeObserver(onRemove, "satchel-storage-changed");
+ executeSoon(() => deferred.resolve());
+ }
+ }, "satchel-storage-changed", false);
+ yield deferred.promise;
+
+ // Send GetSuggestions again.
+ gMsgMan.sendAsyncMessage(TEST_MSG, {
+ type: "GetSuggestions",
+ data: {
+ engineName: engine.name,
+ searchString: searchStr,
+ remoteTimeout: SUGGESTIONS_TIMEOUT,
+ },
+ });
+
+ // The formHistory suggestions in the Suggestions response should be empty.
+ msg = yield waitForTestMsg("Suggestions");
+ checkMsg(msg, {
+ type: "Suggestions",
+ data: {
+ engineName: engine.name,
+ searchString: searchStr,
+ formHistory: [],
+ remote: [searchStr + "foo", searchStr + "bar"],
+ },
+ });
+
+ // Finally, clean up by removing the test engine.
+ Services.search.removeEngine(engine);
+ yield waitForTestMsg("CurrentState");
+});
+
+function buffersEqual(actualArrayBuffer, expectedArrayBuffer) {
+ let expectedView = new Int8Array(expectedArrayBuffer);
+ let actualView = new Int8Array(actualArrayBuffer);
+ for (let i = 0; i < expectedView.length; i++) {
+ if (actualView[i] != expectedView[i]) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function arrayBufferEqual(actualArrayBuffer, expectedArrayBuffer) {
+ ok(actualArrayBuffer instanceof ArrayBuffer, "Actual value is ArrayBuffer.");
+ ok(expectedArrayBuffer instanceof ArrayBuffer, "Expected value is ArrayBuffer.");
+ Assert.equal(actualArrayBuffer.byteLength, expectedArrayBuffer.byteLength,
+ "Array buffers have the same length.");
+ ok(buffersEqual(actualArrayBuffer, expectedArrayBuffer), "Buffers are equal.");
+}
+
+function checkArrayBuffers(actual, expected) {
+ if (actual instanceof ArrayBuffer) {
+ arrayBufferEqual(actual, expected);
+ }
+ if (typeof actual == "object") {
+ for (let i in actual) {
+ checkArrayBuffers(actual[i], expected[i]);
+ }
+ }
+}
+
+function checkMsg(actualMsg, expectedMsgData) {
+ let actualMsgData = actualMsg.data;
+ SimpleTest.isDeeply(actualMsg.data, expectedMsgData, "Checking message");
+
+ // Engines contain ArrayBuffers which we have to compare byte by byte and
+ // not as Objects (like SimpleTest.isDeeply does).
+ checkArrayBuffers(actualMsgData, expectedMsgData);
+}
+
+function waitForMsg(name, type) {
+ let deferred = Promise.defer();
+ info("Waiting for " + name + " message " + type + "...");
+ gMsgMan.addMessageListener(name, function onMsg(msg) {
+ info("Received " + name + " message " + msg.data.type + "\n");
+ if (msg.data.type == type) {
+ gMsgMan.removeMessageListener(name, onMsg);
+ deferred.resolve(msg);
+ }
+ });
+ return deferred.promise;
+}
+
+function waitForTestMsg(type) {
+ return waitForMsg(TEST_MSG, type);
+}
+
+function waitForNewEngine(basename, numImages) {
+ info("Waiting for engine to be added: " + basename);
+
+ // Wait for the search events triggered by adding the new engine.
+ // engine-added engine-loaded
+ let expectedSearchEvents = ["CurrentState", "CurrentState"];
+ // engine-changed for each of the images
+ for (let i = 0; i < numImages; i++) {
+ expectedSearchEvents.push("CurrentState");
+ }
+ let eventPromises = expectedSearchEvents.map(e => waitForTestMsg(e));
+
+ // Wait for addEngine().
+ let addDeferred = Promise.defer();
+ let url = getRootDirectory(gTestPath) + basename;
+ Services.search.addEngine(url, null, "", false, {
+ onSuccess: function (engine) {
+ info("Search engine added: " + basename);
+ addDeferred.resolve(engine);
+ },
+ onError: function (errCode) {
+ ok(false, "addEngine failed with error code " + errCode);
+ addDeferred.reject();
+ },
+ });
+
+ return Promise.all([addDeferred.promise].concat(eventPromises));
+}
+
+function addTab() {
+ let deferred = Promise.defer();
+ let tab = gBrowser.addTab();
+ gBrowser.selectedTab = tab;
+ tab.linkedBrowser.addEventListener("load", function load() {
+ tab.linkedBrowser.removeEventListener("load", load, true);
+ let url = getRootDirectory(gTestPath) + TEST_CONTENT_SCRIPT_BASENAME;
+ gMsgMan = tab.linkedBrowser.messageManager;
+ gMsgMan.sendAsyncMessage(CONTENT_SEARCH_MSG, {
+ type: "AddToWhitelist",
+ data: ["about:blank"],
+ });
+ waitForMsg(CONTENT_SEARCH_MSG, "AddToWhitelistAck").then(() => {
+ gMsgMan.loadFrameScript(url, false);
+ deferred.resolve();
+ });
+ }, true);
+ registerCleanupFunction(() => gBrowser.removeTab(tab));
+ return deferred.promise;
+}
+
+var currentStateObj = Task.async(function* () {
+ let state = {
+ engines: [],
+ currentEngine: yield currentEngineObj(),
+ };
+ for (let engine of Services.search.getVisibleEngines()) {
+ let uri = engine.getIconURLBySize(16, 16);
+ state.engines.push({
+ name: engine.name,
+ iconBuffer: yield arrayBufferFromDataURI(uri),
+ hidden: false,
+ });
+ }
+ return state;
+});
+
+var currentEngineObj = Task.async(function* () {
+ let engine = Services.search.currentEngine;
+ let uriFavicon = engine.getIconURLBySize(16, 16);
+ let bundle = Services.strings.createBundle("chrome://global/locale/autocomplete.properties");
+ return {
+ name: engine.name,
+ placeholder: bundle.formatStringFromName("searchWithEngine", [engine.name], 1),
+ iconBuffer: yield arrayBufferFromDataURI(uriFavicon),
+ };
+});
+
+function arrayBufferFromDataURI(uri) {
+ if (!uri) {
+ return Promise.resolve(null);
+ }
+ let deferred = Promise.defer();
+ let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
+ createInstance(Ci.nsIXMLHttpRequest);
+ xhr.open("GET", uri, true);
+ xhr.responseType = "arraybuffer";
+ xhr.onerror = () => {
+ deferred.resolve(null);
+ };
+ xhr.onload = () => {
+ deferred.resolve(xhr.response);
+ };
+ try {
+ xhr.send();
+ }
+ catch (err) {
+ return Promise.resolve(null);
+ }
+ return deferred.promise;
+}
diff --git a/browser/modules/test/browser_NetworkPrioritizer.js b/browser/modules/test/browser_NetworkPrioritizer.js
new file mode 100644
index 000000000..91557b0fd
--- /dev/null
+++ b/browser/modules/test/browser_NetworkPrioritizer.js
@@ -0,0 +1,165 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/** Tests for NetworkPrioritizer.jsm (Bug 514490) **/
+
+const LOWEST = Ci.nsISupportsPriority.PRIORITY_LOWEST;
+const LOW = Ci.nsISupportsPriority.PRIORITY_LOW;
+const NORMAL = Ci.nsISupportsPriority.PRIORITY_NORMAL;
+const HIGH = Ci.nsISupportsPriority.PRIORITY_HIGH;
+const HIGHEST = Ci.nsISupportsPriority.PRIORITY_HIGHEST;
+
+const DELTA = NORMAL - LOW; // lower value means higher priority
+
+// Test helper functions.
+// getPriority and setPriority can take a tab or a Browser
+function* getPriority(aBrowser) {
+ if (aBrowser.localName == "tab")
+ aBrowser = aBrowser.linkedBrowser;
+
+ return yield ContentTask.spawn(aBrowser, null, function* () {
+ return docShell.QueryInterface(Components.interfaces.nsIWebNavigation)
+ .QueryInterface(Components.interfaces.nsIDocumentLoader)
+ .loadGroup
+ .QueryInterface(Components.interfaces.nsISupportsPriority)
+ .priority;
+ });
+}
+
+function* setPriority(aBrowser, aPriority) {
+ if (aBrowser.localName == "tab")
+ aBrowser = aBrowser.linkedBrowser;
+
+ yield ContentTask.spawn(aBrowser, aPriority, function* (aPriority) {
+ docShell.QueryInterface(Components.interfaces.nsIWebNavigation)
+ .QueryInterface(Components.interfaces.nsIDocumentLoader)
+ .loadGroup
+ .QueryInterface(Ci.nsISupportsPriority)
+ .priority = aPriority;
+ });
+}
+
+function* isWindowState(aWindow, aTabPriorities) {
+ let browsers = aWindow.gBrowser.browsers;
+ // Make sure we have the right number of tabs & priorities
+ is(browsers.length, aTabPriorities.length,
+ "Window has expected number of tabs");
+ // aState should be in format [ priority, priority, priority ]
+ for (let i = 0; i < browsers.length; i++) {
+ is(yield getPriority(browsers[i]), aTabPriorities[i],
+ "Tab " + i + " had expected priority");
+ }
+}
+
+function promiseWaitForFocus(aWindow) {
+ return new Promise((resolve) => {
+ waitForFocus(resolve, aWindow);
+ });
+}
+
+add_task(function*() {
+ // This is the real test. It creates multiple tabs & windows, changes focus,
+ // closes windows/tabs to make sure we behave correctly.
+ // This test assumes that no priorities have been adjusted and the loadgroup
+ // priority starts at 0.
+
+ // Call window "window_A" to make the test easier to follow
+ let window_A = window;
+
+ // Test 1 window, 1 tab case.
+ yield isWindowState(window_A, [HIGH]);
+
+ // Exising tab is tab_A1
+ let tab_A2 = window_A.gBrowser.addTab("http://example.com");
+ let tab_A3 = window_A.gBrowser.addTab("about:config");
+ yield BrowserTestUtils.browserLoaded(tab_A3.linkedBrowser);
+
+ // tab_A2 isn't focused yet
+ yield isWindowState(window_A, [HIGH, NORMAL, NORMAL]);
+
+ // focus tab_A2 & make sure priority got updated
+ window_A.gBrowser.selectedTab = tab_A2;
+ yield isWindowState(window_A, [NORMAL, HIGH, NORMAL]);
+
+ window_A.gBrowser.removeTab(tab_A2);
+ // Next tab is auto selected synchronously as part of removeTab, and we
+ // expect the priority to be updated immediately.
+ yield isWindowState(window_A, [NORMAL, HIGH]);
+
+ // Open another window then play with focus
+ let window_B = yield BrowserTestUtils.openNewBrowserWindow();
+
+ yield promiseWaitForFocus(window_B);
+ yield isWindowState(window_A, [LOW, NORMAL]);
+ yield isWindowState(window_B, [HIGH]);
+
+ yield promiseWaitForFocus(window_A);
+ yield isWindowState(window_A, [NORMAL, HIGH]);
+ yield isWindowState(window_B, [NORMAL]);
+
+ yield promiseWaitForFocus(window_B);
+ yield isWindowState(window_A, [LOW, NORMAL]);
+ yield isWindowState(window_B, [HIGH]);
+
+ // Cleanup
+ window_A.gBrowser.removeTab(tab_A3);
+ yield BrowserTestUtils.closeWindow(window_B);
+});
+
+add_task(function*() {
+ // This is more a test of nsLoadGroup and how it handles priorities. But since
+ // we depend on its behavior, it's good to test it. This is testing that there
+ // are no errors if we adjust beyond nsISupportsPriority's bounds.
+
+ yield promiseWaitForFocus();
+
+ let tab1 = gBrowser.tabs[0];
+ let oldPriority = yield getPriority(tab1);
+
+ // Set the priority of tab1 to the lowest possible. Selecting the other tab
+ // will try to lower it
+ yield setPriority(tab1, LOWEST);
+
+ let tab2 = gBrowser.addTab("http://example.com");
+ yield BrowserTestUtils.browserLoaded(tab2.linkedBrowser);
+ gBrowser.selectedTab = tab2;
+ is(yield getPriority(tab1), LOWEST - DELTA, "Can adjust priority beyond 'lowest'");
+
+ // Now set priority to "highest" and make sure that no errors occur.
+ yield setPriority(tab1, HIGHEST);
+ gBrowser.selectedTab = tab1;
+
+ is(yield getPriority(tab1), HIGHEST + DELTA, "Can adjust priority beyond 'highest'");
+
+ // Cleanup
+ gBrowser.removeTab(tab2);
+ yield setPriority(tab1, oldPriority);
+});
+
+add_task(function*() {
+ // This tests that the priority doesn't get lost when switching the browser's remoteness
+
+ if (!gMultiProcessBrowser) {
+ return;
+ }
+
+ let browser = gBrowser.selectedBrowser;
+
+ browser.loadURI("http://example.com");
+ yield BrowserTestUtils.browserLoaded(browser);
+ ok(browser.isRemoteBrowser, "web page should be loaded in remote browser");
+ is(yield getPriority(browser), HIGH, "priority of selected tab should be 'high'");
+
+ browser.loadURI("about:rights");
+ yield BrowserTestUtils.browserLoaded(browser);
+ ok(!browser.isRemoteBrowser, "about:rights should switch browser to non-remote");
+ is(yield getPriority(browser), HIGH,
+ "priority of selected tab should be 'high' when going from remote to non-remote");
+
+ browser.loadURI("http://example.com");
+ yield BrowserTestUtils.browserLoaded(browser);
+ ok(browser.isRemoteBrowser, "going from about:rights to web page should switch browser to remote");
+ is(yield getPriority(browser), HIGH,
+ "priority of selected tab should be 'high' when going from non-remote to remote");
+});
diff --git a/browser/modules/test/browser_PermissionUI.js b/browser/modules/test/browser_PermissionUI.js
new file mode 100644
index 000000000..006bc5e66
--- /dev/null
+++ b/browser/modules/test/browser_PermissionUI.js
@@ -0,0 +1,445 @@
+/**
+ * These tests test the ability for the PermissionUI module to open
+ * permission prompts to the user. It also tests to ensure that
+ * add-ons can introduce their own permission prompts.
+ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/Integration.jsm", this);
+Cu.import("resource:///modules/PermissionUI.jsm", this);
+
+/**
+ * Given a <xul:browser> at some non-internal web page,
+ * return something that resembles an nsIContentPermissionRequest,
+ * using the browsers currently loaded document to get a principal.
+ *
+ * @param browser (<xul:browser>)
+ * The browser that we'll create a nsIContentPermissionRequest
+ * for.
+ * @returns A nsIContentPermissionRequest-ish object.
+ */
+function makeMockPermissionRequest(browser) {
+ let result = {
+ types: null,
+ principal: browser.contentPrincipal,
+ requester: null,
+ _cancelled: false,
+ cancel() {
+ this._cancelled = true;
+ },
+ _allowed: false,
+ allow() {
+ this._allowed = true;
+ },
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIContentPermissionRequest]),
+ };
+
+ // In the e10s-case, nsIContentPermissionRequest will have
+ // element defined. window is defined otherwise.
+ if (browser.isRemoteBrowser) {
+ result.element = browser;
+ } else {
+ result.window = browser.contentWindow;
+ }
+
+ return result;
+}
+
+/**
+ * For an opened PopupNotification, clicks on the main action,
+ * and waits for the panel to fully close.
+ *
+ * @return {Promise}
+ * Resolves once the panel has fired the "popuphidden"
+ * event.
+ */
+function clickMainAction() {
+ let removePromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popuphidden");
+ let popupNotification = getPopupNotificationNode();
+ popupNotification.button.click();
+ return removePromise;
+}
+
+/**
+ * For an opened PopupNotification, clicks on a secondary action,
+ * and waits for the panel to fully close.
+ *
+ * @param {int} index
+ * The 0-indexed index of the secondary menuitem to choose.
+ * @return {Promise}
+ * Resolves once the panel has fired the "popuphidden"
+ * event.
+ */
+function clickSecondaryAction(index) {
+ let removePromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popuphidden");
+ let popupNotification = getPopupNotificationNode();
+ let menuitems = popupNotification.children;
+ menuitems[index].click();
+ return removePromise;
+}
+
+/**
+ * Makes sure that 1 (and only 1) <xul:popupnotification> is being displayed
+ * by PopupNotification, and then returns that <xul:popupnotification>.
+ *
+ * @return {<xul:popupnotification>}
+ */
+function getPopupNotificationNode() {
+ // PopupNotification is a bit overloaded here, so to be
+ // clear, popupNotifications is a list of <xul:popupnotification>
+ // nodes.
+ let popupNotifications = PopupNotifications.panel.childNodes;
+ Assert.equal(popupNotifications.length, 1,
+ "Should be showing a <xul:popupnotification>");
+ return popupNotifications[0];
+}
+
+/**
+ * Tests the PermissionPromptForRequest prototype to ensure that a prompt
+ * can be displayed. Does not test permission handling.
+ */
+add_task(function* test_permission_prompt_for_request() {
+ yield BrowserTestUtils.withNewTab({
+ gBrowser,
+ url: "http://example.com/",
+ }, function*(browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+ let mainAction = {
+ label: "Main",
+ accessKey: "M",
+ };
+ let secondaryAction = {
+ label: "Secondary",
+ accessKey: "S",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ let TestPrompt = {
+ __proto__: PermissionUI.PermissionPromptForRequestPrototype,
+ request: mockRequest,
+ notificationID: kTestNotificationID,
+ message: kTestMessage,
+ promptActions: [mainAction, secondaryAction],
+ };
+
+ let shownPromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+ TestPrompt.prompt();
+ yield shownPromise;
+ let notification =
+ PopupNotifications.getNotification(kTestNotificationID, browser);
+ Assert.ok(notification, "Should have gotten the notification");
+
+ Assert.equal(notification.message, kTestMessage,
+ "Should be showing the right message");
+ Assert.equal(notification.mainAction.label, mainAction.label,
+ "The main action should have the right label");
+ Assert.equal(notification.mainAction.accessKey, mainAction.accessKey,
+ "The main action should have the right access key");
+ Assert.equal(notification.secondaryActions.length, 1,
+ "There should only be 1 secondary action");
+ Assert.equal(notification.secondaryActions[0].label, secondaryAction.label,
+ "The secondary action should have the right label");
+ Assert.equal(notification.secondaryActions[0].accessKey,
+ secondaryAction.accessKey,
+ "The secondary action should have the right access key");
+ Assert.ok(notification.options.displayURI.equals(mockRequest.principal.URI),
+ "Should be showing the URI of the requesting page");
+
+ let removePromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popuphidden");
+ notification.remove();
+ yield removePromise;
+ });
+});
+
+/**
+ * Tests that if the PermissionPrompt sets displayURI to false in popupOptions,
+ * then there is no URI shown on the popupnotification.
+ */
+add_task(function* test_permission_prompt_for_popupOptions() {
+ yield BrowserTestUtils.withNewTab({
+ gBrowser,
+ url: "http://example.com/",
+ }, function*(browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+ let mainAction = {
+ label: "Main",
+ accessKey: "M",
+ };
+ let secondaryAction = {
+ label: "Secondary",
+ accessKey: "S",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ let TestPrompt = {
+ __proto__: PermissionUI.PermissionPromptForRequestPrototype,
+ request: mockRequest,
+ notificationID: kTestNotificationID,
+ message: kTestMessage,
+ promptActions: [mainAction, secondaryAction],
+ popupOptions: {
+ displayURI: false,
+ },
+ };
+
+ let shownPromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+ TestPrompt.prompt();
+ yield shownPromise;
+ let notification =
+ PopupNotifications.getNotification(kTestNotificationID, browser);
+
+ Assert.ok(!notification.options.displayURI,
+ "Should not show the URI of the requesting page");
+
+ let removePromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popuphidden");
+ notification.remove();
+ yield removePromise;
+ });
+});
+
+/**
+ * Tests that if the PermissionPrompt has the permissionKey
+ * set that permissions can be set properly by the user. Also
+ * ensures that callbacks for promptActions are properly fired.
+ */
+add_task(function* test_with_permission_key() {
+ yield BrowserTestUtils.withNewTab({
+ gBrowser,
+ url: "http://example.com",
+ }, function*(browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+ const kTestPermissionKey = "test-permission-key";
+
+ let allowed = false;
+ let mainAction = {
+ label: "Allow",
+ accessKey: "M",
+ action: Ci.nsIPermissionManager.ALLOW_ACTION,
+ expiryType: Ci.nsIPermissionManager.EXPIRE_SESSION,
+ callback: function() {
+ allowed = true;
+ }
+ };
+
+ let denied = false;
+ let secondaryAction = {
+ label: "Deny",
+ accessKey: "D",
+ action: Ci.nsIPermissionManager.DENY_ACTION,
+ expiryType: Ci.nsIPermissionManager.EXPIRE_SESSION,
+ callback: function() {
+ denied = true;
+ }
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ let principal = mockRequest.principal;
+ registerCleanupFunction(function() {
+ Services.perms.removeFromPrincipal(principal, kTestPermissionKey);
+ });
+
+ let TestPrompt = {
+ __proto__: PermissionUI.PermissionPromptForRequestPrototype,
+ request: mockRequest,
+ notificationID: kTestNotificationID,
+ permissionKey: kTestPermissionKey,
+ message: kTestMessage,
+ promptActions: [mainAction, secondaryAction],
+ };
+
+ let shownPromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+ TestPrompt.prompt();
+ yield shownPromise;
+ let notification =
+ PopupNotifications.getNotification(kTestNotificationID, browser);
+ Assert.ok(notification, "Should have gotten the notification");
+
+ let curPerm =
+ Services.perms.testPermissionFromPrincipal(principal,
+ kTestPermissionKey);
+ Assert.equal(curPerm, Ci.nsIPermissionManager.UNKNOWN_ACTION,
+ "Should be no permission set to begin with.");
+
+ // First test denying the permission request.
+ Assert.equal(notification.secondaryActions.length, 1,
+ "There should only be 1 secondary action");
+ yield clickSecondaryAction(0);
+ curPerm = Services.perms.testPermissionFromPrincipal(principal,
+ kTestPermissionKey);
+ Assert.equal(curPerm, Ci.nsIPermissionManager.DENY_ACTION,
+ "Should have denied the action");
+ Assert.ok(denied, "The secondaryAction callback should have fired");
+ Assert.ok(!allowed, "The mainAction callback should not have fired");
+ Assert.ok(mockRequest._cancelled,
+ "The request should have been cancelled");
+ Assert.ok(!mockRequest._allowed,
+ "The request should not have been allowed");
+
+ // Clear the permission and pretend we never denied
+ Services.perms.removeFromPrincipal(principal, kTestPermissionKey);
+ denied = false;
+ mockRequest._cancelled = false;
+
+ // Bring the PopupNotification back up now...
+ shownPromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+ TestPrompt.prompt();
+ yield shownPromise;
+
+ // Next test allowing the permission request.
+ yield clickMainAction();
+ curPerm = Services.perms.testPermissionFromPrincipal(principal,
+ kTestPermissionKey);
+ Assert.equal(curPerm, Ci.nsIPermissionManager.ALLOW_ACTION,
+ "Should have allowed the action");
+ Assert.ok(!denied, "The secondaryAction callback should not have fired");
+ Assert.ok(allowed, "The mainAction callback should have fired");
+ Assert.ok(!mockRequest._cancelled,
+ "The request should not have been cancelled");
+ Assert.ok(mockRequest._allowed,
+ "The request should have been allowed");
+ });
+});
+
+/**
+ * Tests that the onBeforeShow method will be called before
+ * the popup appears.
+ */
+add_task(function* test_on_before_show() {
+ yield BrowserTestUtils.withNewTab({
+ gBrowser,
+ url: "http://example.com",
+ }, function*(browser) {
+ const kTestNotificationID = "test-notification";
+ const kTestMessage = "Test message";
+
+ let mainAction = {
+ label: "Test action",
+ accessKey: "T",
+ };
+
+ let mockRequest = makeMockPermissionRequest(browser);
+ let beforeShown = false;
+
+ let TestPrompt = {
+ __proto__: PermissionUI.PermissionPromptForRequestPrototype,
+ request: mockRequest,
+ notificationID: kTestNotificationID,
+ message: kTestMessage,
+ promptActions: [mainAction],
+ onBeforeShow() {
+ beforeShown = true;
+ }
+ };
+
+ let shownPromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+ TestPrompt.prompt();
+ Assert.ok(beforeShown, "Should have called onBeforeShown");
+ yield shownPromise;
+ let notification =
+ PopupNotifications.getNotification(kTestNotificationID, browser);
+
+ let removePromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popuphidden");
+ notification.remove();
+ yield removePromise;
+ });
+});
+
+/**
+ * Tests that we can open a PermissionPrompt without wrapping a
+ * nsIContentPermissionRequest.
+ */
+add_task(function* test_no_request() {
+ yield BrowserTestUtils.withNewTab({
+ gBrowser,
+ url: "http://example.com",
+ }, function*(browser) {
+ const kTestNotificationID = "test-notification";
+ let allowed = false;
+ let mainAction = {
+ label: "Allow",
+ accessKey: "M",
+ callback: function() {
+ allowed = true;
+ }
+ };
+
+ let denied = false;
+ let secondaryAction = {
+ label: "Deny",
+ accessKey: "D",
+ callback: function() {
+ denied = true;
+ }
+ };
+
+ const kTestMessage = "Test message with no request";
+ let principal = browser.contentPrincipal;
+ let beforeShown = false;
+
+ let TestPrompt = {
+ __proto__: PermissionUI.PermissionPromptPrototype,
+ notificationID: kTestNotificationID,
+ principal,
+ browser,
+ message: kTestMessage,
+ promptActions: [mainAction, secondaryAction],
+ onBeforeShow() {
+ beforeShown = true;
+ }
+ };
+
+ let shownPromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+ TestPrompt.prompt();
+ Assert.ok(beforeShown, "Should have called onBeforeShown");
+ yield shownPromise;
+ let notification =
+ PopupNotifications.getNotification(kTestNotificationID, browser);
+
+ Assert.equal(notification.message, kTestMessage,
+ "Should be showing the right message");
+ Assert.equal(notification.mainAction.label, mainAction.label,
+ "The main action should have the right label");
+ Assert.equal(notification.mainAction.accessKey, mainAction.accessKey,
+ "The main action should have the right access key");
+ Assert.equal(notification.secondaryActions.length, 1,
+ "There should only be 1 secondary action");
+ Assert.equal(notification.secondaryActions[0].label, secondaryAction.label,
+ "The secondary action should have the right label");
+ Assert.equal(notification.secondaryActions[0].accessKey,
+ secondaryAction.accessKey,
+ "The secondary action should have the right access key");
+ Assert.ok(notification.options.displayURI.equals(principal.URI),
+ "Should be showing the URI of the requesting page");
+
+ // First test denying the permission request.
+ Assert.equal(notification.secondaryActions.length, 1,
+ "There should only be 1 secondary action");
+ yield clickSecondaryAction(0);
+ Assert.ok(denied, "The secondaryAction callback should have fired");
+ Assert.ok(!allowed, "The mainAction callback should not have fired");
+
+ shownPromise =
+ BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+ TestPrompt.prompt();
+ yield shownPromise;
+
+ // Next test allowing the permission request.
+ yield clickMainAction();
+ Assert.ok(allowed, "The mainAction callback should have fired");
+ });
+});
diff --git a/browser/modules/test/browser_ProcessHangNotifications.js b/browser/modules/test/browser_ProcessHangNotifications.js
new file mode 100644
index 000000000..597be611a
--- /dev/null
+++ b/browser/modules/test/browser_ProcessHangNotifications.js
@@ -0,0 +1,189 @@
+
+Cu.import("resource://gre/modules/UpdateUtils.jsm");
+
+function getNotificationBox(aWindow) {
+ return aWindow.document.getElementById("high-priority-global-notificationbox");
+}
+
+function promiseNotificationShown(aWindow, aName) {
+ return new Promise((resolve) => {
+ let notification = getNotificationBox(aWindow);
+ notification.addEventListener("AlertActive", function active() {
+ notification.removeEventListener("AlertActive", active, true);
+ is(notification.allNotifications.length, 1, "Notification Displayed.");
+ resolve(notification);
+ });
+ });
+}
+
+function promiseReportCallMade(aValue) {
+ return new Promise((resolve) => {
+ let old = gTestHangReport.testCallback;
+ gTestHangReport.testCallback = function (val) {
+ gTestHangReport.testCallback = old;
+ is(aValue, val, "was the correct method call made on the hang report object?");
+ resolve();
+ };
+ });
+}
+
+function pushPrefs(...aPrefs) {
+ return new Promise((resolve) => {
+ SpecialPowers.pushPrefEnv({"set": aPrefs}, resolve);
+ resolve();
+ });
+}
+
+function popPrefs() {
+ return new Promise((resolve) => {
+ SpecialPowers.popPrefEnv(resolve);
+ resolve();
+ });
+}
+
+let gTestHangReport = {
+ SLOW_SCRIPT: 1,
+ PLUGIN_HANG: 2,
+
+ TEST_CALLBACK_CANCELED: 1,
+ TEST_CALLBACK_TERMSCRIPT: 2,
+ TEST_CALLBACK_TERMPLUGIN: 3,
+
+ _hangType: 1,
+ _tcb: function (aCallbackType) {},
+
+ get hangType() {
+ return this._hangType;
+ },
+
+ set hangType(aValue) {
+ this._hangType = aValue;
+ },
+
+ set testCallback(aValue) {
+ this._tcb = aValue;
+ },
+
+ QueryInterface: function (aIID) {
+ if (aIID.equals(Components.interfaces.nsIHangReport) ||
+ aIID.equals(Components.interfaces.nsISupports))
+ return this;
+ throw Components.results.NS_NOINTERFACE;
+ },
+
+ userCanceled: function () {
+ this._tcb(this.TEST_CALLBACK_CANCELED);
+ },
+
+ terminateScript: function () {
+ this._tcb(this.TEST_CALLBACK_TERMSCRIPT);
+ },
+
+ terminatePlugin: function () {
+ this._tcb(this.TEST_CALLBACK_TERMPLUGIN);
+ },
+
+ isReportForBrowser: function(aFrameLoader) {
+ return true;
+ }
+};
+
+// on dev edition we add a button for js debugging of hung scripts.
+let buttonCount = (UpdateUtils.UpdateChannel == "aurora" ? 3 : 2);
+
+/**
+ * Test if hang reports receive a terminate script callback when the user selects
+ * stop in response to a script hang.
+ */
+
+add_task(function* terminateScriptTest() {
+ let promise = promiseNotificationShown(window, "process-hang");
+ Services.obs.notifyObservers(gTestHangReport, "process-hang-report", null);
+ let notification = yield promise;
+
+ let buttons = notification.currentNotification.getElementsByTagName("button");
+ // Fails on aurora on-push builds, bug 1232204
+ // is(buttons.length, buttonCount, "proper number of buttons");
+
+ // Click the "Stop It" button, we should get a terminate script callback
+ gTestHangReport.hangType = gTestHangReport.SLOW_SCRIPT;
+ promise = promiseReportCallMade(gTestHangReport.TEST_CALLBACK_TERMSCRIPT);
+ buttons[0].click();
+ yield promise;
+});
+
+/**
+ * Test if hang reports receive user canceled callbacks after a user selects wait
+ * and the browser frees up from a script hang on its own.
+ */
+
+add_task(function* waitForScriptTest() {
+ let promise = promiseNotificationShown(window, "process-hang");
+ Services.obs.notifyObservers(gTestHangReport, "process-hang-report", null);
+ let notification = yield promise;
+
+ let buttons = notification.currentNotification.getElementsByTagName("button");
+ // Fails on aurora on-push builds, bug 1232204
+ // is(buttons.length, buttonCount, "proper number of buttons");
+
+ yield pushPrefs(["browser.hangNotification.waitPeriod", 1000]);
+
+ function nocbcheck() {
+ ok(false, "received a callback?");
+ }
+ let oldcb = gTestHangReport.testCallback;
+ gTestHangReport.testCallback = nocbcheck;
+ // Click the "Wait" button this time, we shouldn't get a callback at all.
+ buttons[1].click();
+ gTestHangReport.testCallback = oldcb;
+
+ // send another hang pulse, we should not get a notification here
+ Services.obs.notifyObservers(gTestHangReport, "process-hang-report", null);
+ is(notification.currentNotification, null, "no notification should be visible");
+
+ gTestHangReport.testCallback = function() {};
+ Services.obs.notifyObservers(gTestHangReport, "clear-hang-report", null);
+ gTestHangReport.testCallback = oldcb;
+
+ yield popPrefs();
+});
+
+/**
+ * Test if hang reports receive user canceled callbacks after the content
+ * process stops sending hang notifications.
+ */
+
+add_task(function* hangGoesAwayTest() {
+ yield pushPrefs(["browser.hangNotification.expiration", 1000]);
+
+ let promise = promiseNotificationShown(window, "process-hang");
+ Services.obs.notifyObservers(gTestHangReport, "process-hang-report", null);
+ yield promise;
+
+ promise = promiseReportCallMade(gTestHangReport.TEST_CALLBACK_CANCELED);
+ Services.obs.notifyObservers(gTestHangReport, "clear-hang-report", null);
+ yield promise;
+
+ yield popPrefs();
+});
+
+/**
+ * Tests if hang reports receive a terminate plugin callback when the user selects
+ * stop in response to a plugin hang.
+ */
+
+add_task(function* terminatePluginTest() {
+ let promise = promiseNotificationShown(window, "process-hang");
+ Services.obs.notifyObservers(gTestHangReport, "process-hang-report", null);
+ let notification = yield promise;
+
+ let buttons = notification.currentNotification.getElementsByTagName("button");
+ // Fails on aurora on-push builds, bug 1232204
+ // is(buttons.length, buttonCount, "proper number of buttons");
+
+ // Click the "Stop It" button, we should get a terminate script callback
+ gTestHangReport.hangType = gTestHangReport.PLUGIN_HANG;
+ promise = promiseReportCallMade(gTestHangReport.TEST_CALLBACK_TERMPLUGIN);
+ buttons[0].click();
+ yield promise;
+});
diff --git a/browser/modules/test/browser_SelfSupportBackend.js b/browser/modules/test/browser_SelfSupportBackend.js
new file mode 100644
index 000000000..9e2c1d181
--- /dev/null
+++ b/browser/modules/test/browser_SelfSupportBackend.js
@@ -0,0 +1,214 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Pass an empty scope object to the import to prevent "leaked window property"
+// errors in tests.
+var Preferences = Cu.import("resource://gre/modules/Preferences.jsm", {}).Preferences;
+var PromiseUtils = Cu.import("resource://gre/modules/PromiseUtils.jsm", {}).PromiseUtils;
+var SelfSupportBackend =
+ Cu.import("resource:///modules/SelfSupportBackend.jsm", {}).SelfSupportBackend;
+
+const PREF_SELFSUPPORT_ENABLED = "browser.selfsupport.enabled";
+const PREF_SELFSUPPORT_URL = "browser.selfsupport.url";
+const PREF_UITOUR_ENABLED = "browser.uitour.enabled";
+
+const TEST_WAIT_RETRIES = 60;
+
+const TEST_PAGE_URL = getRootDirectory(gTestPath) + "uitour.html";
+const TEST_PAGE_URL_HTTPS = TEST_PAGE_URL.replace("chrome://mochitests/content/", "https://example.com/");
+
+function sendSessionRestoredNotification() {
+ let selfSupportBackendImpl =
+ Cu.import("resource:///modules/SelfSupportBackend.jsm", {}).SelfSupportBackendInternal;
+ selfSupportBackendImpl.observe(null, "sessionstore-windows-restored", null);
+}
+
+/**
+ * Find a browser, with an IFRAME as parent, who has aURL as the source attribute.
+ *
+ * @param aURL The URL to look for to identify the browser.
+ *
+ * @returns {Object} The browser element or null on failure.
+ */
+function findSelfSupportBrowser(aURL) {
+ let frames = Services.appShell.hiddenDOMWindow.document.querySelectorAll('iframe');
+ for (let frame of frames) {
+ try {
+ let browser = frame.contentDocument.getElementById("win").querySelectorAll('browser')[0];
+ let url = browser.getAttribute("src");
+ if (url == aURL) {
+ return browser;
+ }
+ } catch (e) {
+ continue;
+ }
+ }
+ return null;
+}
+
+/**
+ * Wait for self support page to load.
+ *
+ * @param aURL The URL to look for to identify the browser.
+ *
+ * @returns {Promise} Return a promise which is resolved when SelfSupport page is fully
+ * loaded.
+ */
+function promiseSelfSupportLoad(aURL) {
+ return new Promise((resolve, reject) => {
+ // Find the SelfSupport browser.
+ let browserPromise = waitForConditionPromise(() => !!findSelfSupportBrowser(aURL),
+ "SelfSupport browser not found.",
+ TEST_WAIT_RETRIES);
+
+ // Once found, append a "load" listener to catch page loads.
+ browserPromise.then(() => {
+ let browser = findSelfSupportBrowser(aURL);
+ if (browser.contentDocument.readyState === "complete") {
+ resolve(browser);
+ } else {
+ let handler = () => {
+ browser.removeEventListener("load", handler, true);
+ resolve(browser);
+ };
+ browser.addEventListener("load", handler, true);
+ }
+ }, reject);
+ });
+}
+
+/**
+ * Wait for self support to close.
+ *
+ * @param aURL The URL to look for to identify the browser.
+ *
+ * @returns {Promise} Return a promise which is resolved when SelfSupport browser cannot
+ * be found anymore.
+ */
+function promiseSelfSupportClose(aURL) {
+ return waitForConditionPromise(() => !findSelfSupportBrowser(aURL),
+ "SelfSupport browser is still open.", TEST_WAIT_RETRIES);
+}
+
+/**
+ * Prepare the test environment.
+ */
+add_task(function* setupEnvironment() {
+ // We always run the SelfSupportBackend in tests to check for weird behaviours.
+ // Disable it to test its start-up.
+ SelfSupportBackend.uninit();
+
+ // Testing prefs are set via |user_pref|, so we need to get their value in order
+ // to restore them.
+ let selfSupportEnabled = Preferences.get(PREF_SELFSUPPORT_ENABLED, true);
+ let uitourEnabled = Preferences.get(PREF_UITOUR_ENABLED, false);
+ let selfSupportURL = Preferences.get(PREF_SELFSUPPORT_URL, "");
+
+ // Enable the SelfSupport backend and set the page URL. We also make sure UITour
+ // is enabled.
+ Preferences.set(PREF_SELFSUPPORT_ENABLED, true);
+ Preferences.set(PREF_UITOUR_ENABLED, true);
+ Preferences.set(PREF_SELFSUPPORT_URL, TEST_PAGE_URL_HTTPS);
+
+ // Whitelist the HTTPS page to use UITour.
+ let pageURI = Services.io.newURI(TEST_PAGE_URL_HTTPS, null, null);
+ Services.perms.add(pageURI, "uitour", Services.perms.ALLOW_ACTION);
+
+ registerCleanupFunction(() => {
+ Services.perms.remove(pageURI, "uitour");
+ Preferences.set(PREF_SELFSUPPORT_ENABLED, selfSupportEnabled);
+ Preferences.set(PREF_UITOUR_ENABLED, uitourEnabled);
+ Preferences.set(PREF_SELFSUPPORT_URL, selfSupportURL);
+ });
+});
+
+/**
+ * Test that the self support page can use the UITour API and close itself.
+ */
+add_task(function* test_selfSupport() {
+ // Initialise the SelfSupport backend and trigger the load.
+ SelfSupportBackend.init();
+
+ // SelfSupportBackend waits for "sessionstore-windows-restored" to start loading. Send it.
+ info("Sending sessionstore-windows-restored");
+ sendSessionRestoredNotification();
+
+ // Wait for the SelfSupport page to load.
+ info("Waiting for the SelfSupport local page to load.");
+ let selfSupportBrowser = yield promiseSelfSupportLoad(TEST_PAGE_URL_HTTPS);
+ Assert.ok(!!selfSupportBrowser, "SelfSupport browser must exist.");
+
+ // Get a reference to the UITour API.
+ info("Testing access to the UITour API.");
+ let contentWindow =
+ Cu.waiveXrays(selfSupportBrowser.contentDocument.defaultView);
+ let uitourAPI = contentWindow.Mozilla.UITour;
+
+ // Test the UITour API with a ping.
+ let pingPromise = new Promise((resolve) => {
+ uitourAPI.ping(resolve);
+ });
+ yield pingPromise;
+ info("Ping succeeded");
+
+ let observePromise = ContentTask.spawn(selfSupportBrowser, null, function* checkObserve() {
+ yield new Promise(resolve => {
+ let win = Cu.waiveXrays(content);
+ win.Mozilla.UITour.observe((event, data) => {
+ if (event != "Heartbeat:Engaged") {
+ return;
+ }
+ Assert.equal(data.flowId, "myFlowID", "Check flowId");
+ Assert.ok(!!data.timestamp, "Check timestamp");
+ resolve(data);
+ }, () => {});
+ });
+ });
+
+ info("Notifying Heartbeat:Engaged");
+ UITour.notify("Heartbeat:Engaged", {
+ flowId: "myFlowID",
+ timestamp: Date.now(),
+ });
+ yield observePromise;
+ info("Observed in the hidden frame");
+
+ // Close SelfSupport from content.
+ contentWindow.close();
+
+ // Wait until SelfSupport closes.
+ info("Waiting for the SelfSupport to close.");
+ yield promiseSelfSupportClose(TEST_PAGE_URL_HTTPS);
+
+ // Find the SelfSupport browser, again. We don't expect to find it.
+ selfSupportBrowser = findSelfSupportBrowser(TEST_PAGE_URL_HTTPS);
+ Assert.ok(!selfSupportBrowser, "SelfSupport browser must not exist.");
+
+ // We shouldn't need this, but let's keep it to make sure closing SelfSupport twice
+ // doesn't create any problem.
+ SelfSupportBackend.uninit();
+});
+
+/**
+ * Test that SelfSupportBackend only allows HTTPS.
+ */
+add_task(function* test_selfSupport_noHTTPS() {
+ Preferences.set(PREF_SELFSUPPORT_URL, TEST_PAGE_URL);
+
+ SelfSupportBackend.init();
+
+ // SelfSupportBackend waits for "sessionstore-windows-restored" to start loading. Send it.
+ info("Sending sessionstore-windows-restored");
+ sendSessionRestoredNotification();
+
+ // Find the SelfSupport browser. We don't expect to find it since we are not using https.
+ let selfSupportBrowser = findSelfSupportBrowser(TEST_PAGE_URL);
+ Assert.ok(!selfSupportBrowser, "SelfSupport browser must not exist.");
+
+ // We shouldn't need this, but let's keep it to make sure closing SelfSupport twice
+ // doesn't create any problem.
+ SelfSupportBackend.uninit();
+})
diff --git a/browser/modules/test/browser_UnsubmittedCrashHandler.js b/browser/modules/test/browser_UnsubmittedCrashHandler.js
new file mode 100644
index 000000000..2d78c746b
--- /dev/null
+++ b/browser/modules/test/browser_UnsubmittedCrashHandler.js
@@ -0,0 +1,680 @@
+"use strict";
+
+/**
+ * This suite tests the "unsubmitted crash report" notification
+ * that is seen when we detect pending crash reports on startup.
+ */
+
+const { UnsubmittedCrashHandler } =
+ Cu.import("resource:///modules/ContentCrashHandlers.jsm", this);
+const { FileUtils } =
+ Cu.import("resource://gre/modules/FileUtils.jsm", this);
+const { makeFakeAppDir } =
+ Cu.import("resource://testing-common/AppData.jsm", this);
+const { OS } =
+ Cu.import("resource://gre/modules/osfile.jsm", this);
+
+const DAY = 24 * 60 * 60 * 1000; // milliseconds
+const SERVER_URL = "http://example.com/browser/toolkit/crashreporter/test/browser/crashreport.sjs";
+
+/**
+ * Returns the directly where the browsing is storing the
+ * pending crash reports.
+ *
+ * @returns nsIFile
+ */
+function getPendingCrashReportDir() {
+ // The fake UAppData directory that makeFakeAppDir provides
+ // is just UAppData under the profile directory.
+ return FileUtils.getDir("ProfD", [
+ "UAppData",
+ "Crash Reports",
+ "pending",
+ ], false);
+}
+
+/**
+ * Synchronously deletes all entries inside the pending
+ * crash report directory.
+ */
+function clearPendingCrashReports() {
+ let dir = getPendingCrashReportDir();
+ let entries = dir.directoryEntries;
+
+ while (entries.hasMoreElements()) {
+ let entry = entries.getNext().QueryInterface(Ci.nsIFile);
+ if (entry.isFile()) {
+ entry.remove(false);
+ }
+ }
+}
+
+/**
+ * Randomly generates howMany crash report .dmp and .extra files
+ * to put into the pending crash report directory. We're not
+ * actually creating real crash reports here, just stubbing
+ * out enough of the files to satisfy our notification and
+ * submission code.
+ *
+ * @param howMany (int)
+ * How many pending crash reports to put in the pending
+ * crash report directory.
+ * @param accessDate (Date, optional)
+ * What date to set as the last accessed time on the created
+ * crash reports. This defaults to the current date and time.
+ * @returns Promise
+ */
+function* createPendingCrashReports(howMany, accessDate) {
+ let dir = getPendingCrashReportDir();
+ if (!accessDate) {
+ accessDate = new Date();
+ }
+
+ /**
+ * Helper function for creating a file in the pending crash report
+ * directory.
+ *
+ * @param fileName (string)
+ * The filename for the crash report, not including the
+ * extension. This is usually a UUID.
+ * @param extension (string)
+ * The file extension for the created file.
+ * @param accessDate (Date)
+ * The date to set lastAccessed to.
+ * @param contents (string, optional)
+ * Set this to whatever the file needs to contain, if anything.
+ * @returns Promise
+ */
+ let createFile = (fileName, extension, accessDate, contents) => {
+ let file = dir.clone();
+ file.append(fileName + "." + extension);
+ file.create(Ci.nsILocalFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ let promises = [OS.File.setDates(file.path, accessDate)];
+
+ if (contents) {
+ let encoder = new TextEncoder();
+ let array = encoder.encode(contents);
+ promises.push(OS.File.writeAtomic(file.path, array, {
+ tmpPath: file.path + ".tmp",
+ }));
+ }
+ return Promise.all(promises);
+ }
+
+ let uuidGenerator = Cc["@mozilla.org/uuid-generator;1"]
+ .getService(Ci.nsIUUIDGenerator);
+ // CrashSubmit expects there to be a ServerURL key-value
+ // pair in the .extra file, so we'll satisfy it.
+ let extraFileContents = "ServerURL=" + SERVER_URL;
+
+ return Task.spawn(function*() {
+ let uuids = [];
+ for (let i = 0; i < howMany; ++i) {
+ let uuid = uuidGenerator.generateUUID().toString();
+ // Strip the {}...
+ uuid = uuid.substring(1, uuid.length - 1);
+ yield createFile(uuid, "dmp", accessDate);
+ yield createFile(uuid, "extra", accessDate, extraFileContents);
+ uuids.push(uuid);
+ }
+ return uuids;
+ });
+}
+
+/**
+ * Returns a Promise that resolves once CrashSubmit starts sending
+ * success notifications for crash submission matching the reportIDs
+ * being passed in.
+ *
+ * @param reportIDs (Array<string>)
+ * The IDs for the reports that we expect CrashSubmit to have sent.
+ * @returns Promise
+ */
+function waitForSubmittedReports(reportIDs) {
+ let promises = [];
+ for (let reportID of reportIDs) {
+ let promise = TestUtils.topicObserved("crash-report-status", (subject, data) => {
+ if (data == "success") {
+ let propBag = subject.QueryInterface(Ci.nsIPropertyBag2);
+ let dumpID = propBag.getPropertyAsAString("minidumpID");
+ if (dumpID == reportID) {
+ return true;
+ }
+ }
+ return false;
+ });
+ promises.push(promise);
+ }
+ return Promise.all(promises);
+}
+
+/**
+ * Returns a Promise that resolves once a .dmp.ignore file is created for
+ * the crashes in the pending directory matching the reportIDs being
+ * passed in.
+ *
+ * @param reportIDs (Array<string>)
+ * The IDs for the reports that we expect CrashSubmit to have been
+ * marked for ignoring.
+ * @returns Promise
+ */
+function waitForIgnoredReports(reportIDs) {
+ let dir = getPendingCrashReportDir();
+ let promises = [];
+ for (let reportID of reportIDs) {
+ let file = dir.clone();
+ file.append(reportID + ".dmp.ignore");
+ promises.push(OS.File.exists(file.path));
+ }
+ return Promise.all(promises);
+}
+
+let gNotificationBox;
+
+add_task(function* setup() {
+ // Pending crash reports are stored in the UAppData folder,
+ // which exists outside of the profile folder. In order to
+ // not overwrite / clear pending crash reports for the poor
+ // soul who runs this test, we use AppData.jsm to point to
+ // a special made-up directory inside the profile
+ // directory.
+ yield makeFakeAppDir();
+ // We'll assume that the notifications will be shown in the current
+ // browser window's global notification box.
+ gNotificationBox = document.getElementById("global-notificationbox");
+
+ // If we happen to already be seeing the unsent crash report
+ // notification, it's because the developer running this test
+ // happened to have some unsent reports in their UAppDir.
+ // We'll remove the notification without touching those reports.
+ let notification =
+ gNotificationBox.getNotificationWithValue("pending-crash-reports");
+ if (notification) {
+ notification.close();
+ }
+
+ let env = Cc["@mozilla.org/process/environment;1"]
+ .getService(Components.interfaces.nsIEnvironment);
+ let oldServerURL = env.get("MOZ_CRASHREPORTER_URL");
+ env.set("MOZ_CRASHREPORTER_URL", SERVER_URL);
+
+ // nsBrowserGlue starts up UnsubmittedCrashHandler automatically
+ // so at this point, it is initialized. It's possible that it
+ // was initialized, but is preffed off, so it's inert, so we
+ // shut it down, make sure it's preffed on, and then restart it.
+ // Note that making the component initialize even when it's
+ // disabled is an intentional choice, as this allows for easier
+ // simulation of startup and shutdown.
+ UnsubmittedCrashHandler.uninit();
+ yield SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.crashReports.unsubmittedCheck.enabled", true],
+ ],
+ });
+ UnsubmittedCrashHandler.init();
+
+ registerCleanupFunction(function() {
+ gNotificationBox = null;
+ clearPendingCrashReports();
+ env.set("MOZ_CRASHREPORTER_URL", oldServerURL);
+ });
+});
+
+/**
+ * Tests that if there are no pending crash reports, then the
+ * notification will not show up.
+ */
+add_task(function* test_no_pending_no_notification() {
+ // Make absolutely sure there are no pending crash reports first...
+ clearPendingCrashReports();
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(notification, null,
+ "There should not be a notification if there are no " +
+ "pending crash reports");
+});
+
+/**
+ * Tests that there is a notification if there is one pending
+ * crash report.
+ */
+add_task(function* test_one_pending() {
+ yield createPendingCrashReports(1);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that there is a notification if there is more than one
+ * pending crash report.
+ */
+add_task(function* test_several_pending() {
+ yield createPendingCrashReports(3);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that there is no notification if the only pending crash
+ * reports are over 28 days old. Also checks that if we put a newer
+ * crash with that older set, that we can still get a notification.
+ */
+add_task(function* test_several_pending() {
+ // Let's create some crash reports from 30 days ago.
+ let oldDate = new Date(Date.now() - (30 * DAY));
+ yield createPendingCrashReports(3, oldDate);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(notification, null,
+ "There should not be a notification if there are only " +
+ "old pending crash reports");
+ // Now let's create a new one and check again
+ yield createPendingCrashReports(1);
+ notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that the notification can submit a report.
+ */
+add_task(function* test_can_submit() {
+ let reportIDs = yield createPendingCrashReports(1);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Attempt to submit the notification by clicking on the submit
+ // button
+ let buttons = notification.querySelectorAll(".notification-button");
+ // ...which should be the first button.
+ let submit = buttons[0];
+
+ let promiseReports = waitForSubmittedReports(reportIDs);
+ info("Sending crash report");
+ submit.click();
+ info("Sent!");
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ info("Waiting on reports to be received.");
+ yield promiseReports;
+ info("Received!");
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that the notification can submit multiple reports.
+ */
+add_task(function* test_can_submit_several() {
+ let reportIDs = yield createPendingCrashReports(3);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Attempt to submit the notification by clicking on the submit
+ // button
+ let buttons = notification.querySelectorAll(".notification-button");
+ // ...which should be the first button.
+ let submit = buttons[0];
+
+ let promiseReports = waitForSubmittedReports(reportIDs);
+ info("Sending crash reports");
+ submit.click();
+ info("Sent!");
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ info("Waiting on reports to be received.");
+ yield promiseReports;
+ info("Received!");
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that choosing "Send Always" flips the autoSubmit pref
+ * and sends the pending crash reports.
+ */
+add_task(function* test_can_submit_always() {
+ let pref = "browser.crashReports.unsubmittedCheck.autoSubmit2";
+ Assert.equal(Services.prefs.getBoolPref(pref), false,
+ "We should not be auto-submitting by default");
+
+ let reportIDs = yield createPendingCrashReports(1);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Attempt to submit the notification by clicking on the send all
+ // button
+ let buttons = notification.querySelectorAll(".notification-button");
+ // ...which should be the second button.
+ let sendAll = buttons[1];
+
+ let promiseReports = waitForSubmittedReports(reportIDs);
+ info("Sending crash reports");
+ sendAll.click();
+ info("Sent!");
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ info("Waiting on reports to be received.");
+ yield promiseReports;
+ info("Received!");
+
+ // Make sure the pref was set
+ Assert.equal(Services.prefs.getBoolPref(pref), true,
+ "The autoSubmit pref should have been set");
+
+ // And revert back to default now.
+ Services.prefs.clearUserPref(pref);
+
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if the user has chosen to automatically send
+ * crash reports that no notification is displayed to the
+ * user.
+ */
+add_task(function* test_can_auto_submit() {
+ yield SpecialPowers.pushPrefEnv({ set: [
+ ["browser.crashReports.unsubmittedCheck.autoSubmit2", true],
+ ]});
+
+ let reportIDs = yield createPendingCrashReports(3);
+ let promiseReports = waitForSubmittedReports(reportIDs);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(notification, null, "There should be no notification");
+ info("Waiting on reports to be received.");
+ yield promiseReports;
+ info("Received!");
+
+ clearPendingCrashReports();
+ yield SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Tests that if the user chooses to dismiss the notification,
+ * then the current pending requests won't cause the notification
+ * to appear again in the future.
+ */
+add_task(function* test_can_ignore() {
+ let reportIDs = yield createPendingCrashReports(3);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Dismiss the notification by clicking on the "X" button.
+ let anonyNodes = document.getAnonymousNodes(notification)[0];
+ let closeButton = anonyNodes.querySelector(".close-icon");
+ closeButton.click();
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+ yield waitForIgnoredReports(reportIDs);
+
+ notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(notification, null, "There should be no notification");
+
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if the notification is shown, then the
+ * lastShownDate is set for today.
+ */
+add_task(function* test_last_shown_date() {
+ yield createPendingCrashReports(1);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ let today = UnsubmittedCrashHandler.dateString(new Date());
+ let lastShownDate =
+ UnsubmittedCrashHandler.prefs.getCharPref("lastShownDate");
+ Assert.equal(today, lastShownDate,
+ "Last shown date should be today.");
+
+ UnsubmittedCrashHandler.prefs.clearUserPref("lastShownDate");
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if UnsubmittedCrashHandler is uninit with a
+ * notification still being shown, that
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * set to true.
+ */
+add_task(function* test_shutdown_while_showing() {
+ yield createPendingCrashReports(1);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ UnsubmittedCrashHandler.uninit();
+ let shutdownWhileShowing =
+ UnsubmittedCrashHandler.prefs.getBoolPref("shutdownWhileShowing");
+ Assert.ok(shutdownWhileShowing,
+ "We should have noticed that we uninitted while showing " +
+ "the notification.");
+ UnsubmittedCrashHandler.prefs.clearUserPref("shutdownWhileShowing");
+ UnsubmittedCrashHandler.init();
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if UnsubmittedCrashHandler is uninit after
+ * the notification has been closed, that
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * not set in prefs.
+ */
+add_task(function* test_shutdown_while_not_showing() {
+ let reportIDs = yield createPendingCrashReports(1);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ // Dismiss the notification by clicking on the "X" button.
+ let anonyNodes = document.getAnonymousNodes(notification)[0];
+ let closeButton = anonyNodes.querySelector(".close-icon");
+ closeButton.click();
+ // We'll not wait for the notification to finish its transition -
+ // we'll just remove it right away.
+ gNotificationBox.removeNotification(notification, true);
+
+ yield waitForIgnoredReports(reportIDs);
+
+ UnsubmittedCrashHandler.uninit();
+ Assert.throws(() => {
+ UnsubmittedCrashHandler.prefs.getBoolPref("shutdownWhileShowing");
+ }, "We should have noticed that the notification had closed before " +
+ "uninitting.");
+ UnsubmittedCrashHandler.init();
+
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * set and the lastShownDate is today, then we don't decrement
+ * browser.crashReports.unsubmittedCheck.chancesUntilSuppress.
+ */
+add_task(function* test_dont_decrement_chances_on_same_day() {
+ let initChances =
+ UnsubmittedCrashHandler.prefs.getIntPref("chancesUntilSuppress");
+ Assert.ok(initChances > 1, "We should start with at least 1 chance.");
+
+ yield createPendingCrashReports(1);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ UnsubmittedCrashHandler.uninit();
+
+ gNotificationBox.removeNotification(notification, true);
+
+ let shutdownWhileShowing =
+ UnsubmittedCrashHandler.prefs.getBoolPref("shutdownWhileShowing");
+ Assert.ok(shutdownWhileShowing,
+ "We should have noticed that we uninitted while showing " +
+ "the notification.");
+
+ let today = UnsubmittedCrashHandler.dateString(new Date());
+ let lastShownDate =
+ UnsubmittedCrashHandler.prefs.getCharPref("lastShownDate");
+ Assert.equal(today, lastShownDate,
+ "Last shown date should be today.");
+
+ UnsubmittedCrashHandler.init();
+
+ notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should still be a notification");
+
+ let chances =
+ UnsubmittedCrashHandler.prefs.getIntPref("chancesUntilSuppress");
+
+ Assert.equal(initChances, chances,
+ "We should not have decremented chances.");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if
+ * browser.crashReports.unsubmittedCheck.shutdownWhileShowing is
+ * set and the lastShownDate is before today, then we decrement
+ * browser.crashReports.unsubmittedCheck.chancesUntilSuppress.
+ */
+add_task(function* test_decrement_chances_on_other_day() {
+ let initChances =
+ UnsubmittedCrashHandler.prefs.getIntPref("chancesUntilSuppress");
+ Assert.ok(initChances > 1, "We should start with at least 1 chance.");
+
+ yield createPendingCrashReports(1);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should be a notification");
+
+ UnsubmittedCrashHandler.uninit();
+
+ gNotificationBox.removeNotification(notification, true);
+
+ let shutdownWhileShowing =
+ UnsubmittedCrashHandler.prefs.getBoolPref("shutdownWhileShowing");
+ Assert.ok(shutdownWhileShowing,
+ "We should have noticed that we uninitted while showing " +
+ "the notification.");
+
+ // Now pretend that the notification was shown yesterday.
+ let yesterday = UnsubmittedCrashHandler.dateString(new Date(Date.now() - DAY));
+ UnsubmittedCrashHandler.prefs.setCharPref("lastShownDate", yesterday);
+
+ UnsubmittedCrashHandler.init();
+
+ notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.ok(notification, "There should still be a notification");
+
+ let chances =
+ UnsubmittedCrashHandler.prefs.getIntPref("chancesUntilSuppress");
+
+ Assert.equal(initChances - 1, chances,
+ "We should have decremented our chances.");
+ UnsubmittedCrashHandler.prefs.clearUserPref("chancesUntilSuppress");
+
+ gNotificationBox.removeNotification(notification, true);
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if we've shutdown too many times showing the
+ * notification, and we've run out of chances, then
+ * browser.crashReports.unsubmittedCheck.suppressUntilDate is
+ * set for some days into the future.
+ */
+add_task(function* test_can_suppress_after_chances() {
+ // Pretend that a notification was shown yesterday.
+ let yesterday = UnsubmittedCrashHandler.dateString(new Date(Date.now() - DAY));
+ UnsubmittedCrashHandler.prefs.setCharPref("lastShownDate", yesterday);
+ UnsubmittedCrashHandler.prefs.setBoolPref("shutdownWhileShowing", true);
+ UnsubmittedCrashHandler.prefs.setIntPref("chancesUntilSuppress", 0);
+
+ yield createPendingCrashReports(1);
+ let notification =
+ yield UnsubmittedCrashHandler.checkForUnsubmittedCrashReports();
+ Assert.equal(notification, null,
+ "There should be no notification if we've run out of chances");
+
+ // We should have set suppressUntilDate into the future
+ let suppressUntilDate =
+ UnsubmittedCrashHandler.prefs.getCharPref("suppressUntilDate");
+
+ let today = UnsubmittedCrashHandler.dateString(new Date());
+ Assert.ok(suppressUntilDate > today,
+ "We should be suppressing until some days into the future.");
+
+ UnsubmittedCrashHandler.prefs.clearUserPref("chancesUntilSuppress");
+ UnsubmittedCrashHandler.prefs.clearUserPref("suppressUntilDate");
+ UnsubmittedCrashHandler.prefs.clearUserPref("lastShownDate");
+ clearPendingCrashReports();
+});
+
+/**
+ * Tests that if there's a suppression date set, then no notification
+ * will be shown even if there are pending crash reports.
+ */
+add_task(function* test_suppression() {
+ let future = UnsubmittedCrashHandler.dateString(new Date(Date.now() + (DAY * 5)));
+ UnsubmittedCrashHandler.prefs.setCharPref("suppressUntilDate", future);
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+
+ Assert.ok(UnsubmittedCrashHandler.suppressed,
+ "The UnsubmittedCrashHandler should be suppressed.");
+ UnsubmittedCrashHandler.prefs.clearUserPref("suppressUntilDate");
+
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+});
+
+/**
+ * Tests that if there's a suppression date set, but we've exceeded
+ * it, then we can show the notification again.
+ */
+add_task(function* test_end_suppression() {
+ let yesterday = UnsubmittedCrashHandler.dateString(new Date(Date.now() - DAY));
+ UnsubmittedCrashHandler.prefs.setCharPref("suppressUntilDate", yesterday);
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+
+ Assert.ok(!UnsubmittedCrashHandler.suppressed,
+ "The UnsubmittedCrashHandler should not be suppressed.");
+ Assert.ok(!UnsubmittedCrashHandler.prefs.prefHasUserValue("suppressUntilDate"),
+ "The suppression date should been cleared from preferences.");
+
+ UnsubmittedCrashHandler.uninit();
+ UnsubmittedCrashHandler.init();
+});
diff --git a/browser/modules/test/browser_UsageTelemetry.js b/browser/modules/test/browser_UsageTelemetry.js
new file mode 100644
index 000000000..a84f33a97
--- /dev/null
+++ b/browser/modules/test/browser_UsageTelemetry.js
@@ -0,0 +1,268 @@
+"use strict";
+
+const MAX_CONCURRENT_TABS = "browser.engagement.max_concurrent_tab_count";
+const TAB_EVENT_COUNT = "browser.engagement.tab_open_event_count";
+const MAX_CONCURRENT_WINDOWS = "browser.engagement.max_concurrent_window_count";
+const WINDOW_OPEN_COUNT = "browser.engagement.window_open_event_count";
+const TOTAL_URI_COUNT = "browser.engagement.total_uri_count";
+const UNIQUE_DOMAINS_COUNT = "browser.engagement.unique_domains_count";
+const UNFILTERED_URI_COUNT = "browser.engagement.unfiltered_uri_count";
+
+const TELEMETRY_SUBSESSION_TOPIC = "internal-telemetry-after-subsession-split";
+
+/**
+ * Waits for the web progress listener associated with this tab to fire an
+ * onLocationChange for a non-error page.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ *
+ * @return {Promise}
+ * @resolves When navigating to a non-error page.
+ */
+function browserLocationChanged(browser) {
+ return new Promise(resolve => {
+ let wpl = {
+ onStateChange() {},
+ onSecurityChange() {},
+ onStatusChange() {},
+ onLocationChange(aWebProgress, aRequest, aURI, aFlags) {
+ if (!(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE)) {
+ browser.webProgress.removeProgressListener(filter);
+ filter.removeProgressListener(wpl);
+ resolve();
+ }
+ },
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIWebProgressListener,
+ Ci.nsIWebProgressListener2,
+ ]),
+ };
+ const filter = Cc["@mozilla.org/appshell/component/browser-status-filter;1"]
+ .createInstance(Ci.nsIWebProgress);
+ filter.addProgressListener(wpl, Ci.nsIWebProgress.NOTIFY_ALL);
+ browser.webProgress.addProgressListener(filter, Ci.nsIWebProgress.NOTIFY_ALL);
+ });
+}
+
+/**
+ * An helper that checks the value of a scalar if it's expected to be > 0,
+ * otherwise makes sure that the scalar it's not reported.
+ */
+let checkScalar = (scalars, scalarName, value, msg) => {
+ if (value > 0) {
+ is(scalars[scalarName], value, msg);
+ return;
+ }
+ ok(!(scalarName in scalars), scalarName + " must not be reported.");
+};
+
+/**
+ * Get a snapshot of the scalars and check them against the provided values.
+ */
+let checkScalars = (countsObject) => {
+ const scalars =
+ Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+
+ // Check the expected values. Scalars that are never set must not be reported.
+ checkScalar(scalars, MAX_CONCURRENT_TABS, countsObject.maxTabs,
+ "The maximum tab count must match the expected value.");
+ checkScalar(scalars, TAB_EVENT_COUNT, countsObject.tabOpenCount,
+ "The number of open tab event count must match the expected value.");
+ checkScalar(scalars, MAX_CONCURRENT_WINDOWS, countsObject.maxWindows,
+ "The maximum window count must match the expected value.");
+ checkScalar(scalars, WINDOW_OPEN_COUNT, countsObject.windowsOpenCount,
+ "The number of window open event count must match the expected value.");
+ checkScalar(scalars, TOTAL_URI_COUNT, countsObject.totalURIs,
+ "The total URI count must match the expected value.");
+ checkScalar(scalars, UNIQUE_DOMAINS_COUNT, countsObject.domainCount,
+ "The unique domains count must match the expected value.");
+ checkScalar(scalars, UNFILTERED_URI_COUNT, countsObject.totalUnfilteredURIs,
+ "The unfiltered URI count must match the expected value.");
+};
+
+add_task(function* test_tabsAndWindows() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ let openedTabs = [];
+ let expectedTabOpenCount = 0;
+ let expectedWinOpenCount = 0;
+ let expectedMaxTabs = 0;
+ let expectedMaxWins = 0;
+
+ // Add a new tab and check that the count is right.
+ openedTabs.push(yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"));
+ expectedTabOpenCount = 1;
+ expectedMaxTabs = 2;
+ // This, and all the checks below, also check that initial pages (about:newtab, about:blank, ..)
+ // are not counted by the total_uri_count and the unfiltered_uri_count probes.
+ checkScalars({maxTabs: expectedMaxTabs, tabOpenCount: expectedTabOpenCount, maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount, totalURIs: 0, domainCount: 0,
+ totalUnfilteredURIs: 0});
+
+ // Add two new tabs in the same window.
+ openedTabs.push(yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"));
+ openedTabs.push(yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"));
+ expectedTabOpenCount += 2;
+ expectedMaxTabs += 2;
+ checkScalars({maxTabs: expectedMaxTabs, tabOpenCount: expectedTabOpenCount, maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount, totalURIs: 0, domainCount: 0,
+ totalUnfilteredURIs: 0});
+
+ // Add a new window and then some tabs in it. An empty new windows counts as a tab.
+ let win = yield BrowserTestUtils.openNewBrowserWindow();
+ openedTabs.push(yield BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank"));
+ openedTabs.push(yield BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank"));
+ openedTabs.push(yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"));
+ // The new window started with a new tab, so account for it.
+ expectedTabOpenCount += 4;
+ expectedWinOpenCount += 1;
+ expectedMaxWins = 2;
+ expectedMaxTabs += 4;
+
+ // Remove a tab from the first window, the max shouldn't change.
+ yield BrowserTestUtils.removeTab(openedTabs.pop());
+ checkScalars({maxTabs: expectedMaxTabs, tabOpenCount: expectedTabOpenCount, maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount, totalURIs: 0, domainCount: 0,
+ totalUnfilteredURIs: 0});
+
+ // Remove all the extra windows and tabs.
+ for (let tab of openedTabs) {
+ yield BrowserTestUtils.removeTab(tab);
+ }
+ yield BrowserTestUtils.closeWindow(win);
+
+ // Make sure all the scalars still have the expected values.
+ checkScalars({maxTabs: expectedMaxTabs, tabOpenCount: expectedTabOpenCount, maxWindows: expectedMaxWins,
+ windowsOpenCount: expectedWinOpenCount, totalURIs: 0, domainCount: 0,
+ totalUnfilteredURIs: 0});
+});
+
+add_task(function* test_subsessionSplit() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ // Add a new window (that will have 4 tabs).
+ let win = yield BrowserTestUtils.openNewBrowserWindow();
+ let openedTabs = [];
+ openedTabs.push(yield BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank"));
+ openedTabs.push(yield BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:mozilla"));
+ openedTabs.push(yield BrowserTestUtils.openNewForegroundTab(win.gBrowser, "http://www.example.com"));
+
+ // Check that the scalars have the right values. We expect 2 unfiltered URI loads
+ // (about:mozilla and www.example.com, but no about:blank) and 1 URI totalURIs
+ // (only www.example.com).
+ checkScalars({maxTabs: 5, tabOpenCount: 4, maxWindows: 2, windowsOpenCount: 1,
+ totalURIs: 1, domainCount: 1, totalUnfilteredURIs: 2});
+
+ // Remove a tab.
+ yield BrowserTestUtils.removeTab(openedTabs.pop());
+
+ // Simulate a subsession split by clearing the scalars (via |snapshotScalars|) and
+ // notifying the subsession split topic.
+ Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN,
+ true /* clearScalars */);
+ Services.obs.notifyObservers(null, TELEMETRY_SUBSESSION_TOPIC, "");
+
+ // After a subsession split, only the MAX_CONCURRENT_* scalars must be available
+ // and have the correct value. No tabs, windows or URIs were opened so other scalars
+ // must not be reported.
+ checkScalars({maxTabs: 4, tabOpenCount: 0, maxWindows: 2, windowsOpenCount: 0,
+ totalURIs: 0, domainCount: 0, totalUnfilteredURIs: 0});
+
+ // Remove all the extra windows and tabs.
+ for (let tab of openedTabs) {
+ yield BrowserTestUtils.removeTab(tab);
+ }
+ yield BrowserTestUtils.closeWindow(win);
+});
+
+add_task(function* test_URIAndDomainCounts() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ let checkCounts = (countsObject) => {
+ // Get a snapshot of the scalars and then clear them.
+ const scalars =
+ Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+ checkScalar(scalars, TOTAL_URI_COUNT, countsObject.totalURIs,
+ "The URI scalar must contain the expected value.");
+ checkScalar(scalars, UNIQUE_DOMAINS_COUNT, countsObject.domainCount,
+ "The unique domains scalar must contain the expected value.");
+ checkScalar(scalars, UNFILTERED_URI_COUNT, countsObject.totalUnfilteredURIs,
+ "The unfiltered URI scalar must contain the expected value.");
+ };
+
+ // Check that about:blank doesn't get counted in the URI total.
+ let firstTab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+ checkCounts({totalURIs: 0, domainCount: 0, totalUnfilteredURIs: 0});
+
+ // Open a different page and check the counts.
+ yield BrowserTestUtils.loadURI(firstTab.linkedBrowser, "http://example.com/");
+ yield BrowserTestUtils.browserLoaded(firstTab.linkedBrowser);
+ checkCounts({totalURIs: 1, domainCount: 1, totalUnfilteredURIs: 1});
+
+ // Activating a different tab must not increase the URI count.
+ let secondTab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+ yield BrowserTestUtils.switchTab(gBrowser, firstTab);
+ checkCounts({totalURIs: 1, domainCount: 1, totalUnfilteredURIs: 1});
+ yield BrowserTestUtils.removeTab(secondTab);
+
+ // Open a new window and set the tab to a new address.
+ let newWin = yield BrowserTestUtils.openNewBrowserWindow();
+ yield BrowserTestUtils.loadURI(newWin.gBrowser.selectedBrowser, "http://example.com/");
+ yield BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({totalURIs: 2, domainCount: 1, totalUnfilteredURIs: 2});
+
+ // We should not count AJAX requests.
+ const XHR_URL = "http://example.com/r";
+ yield ContentTask.spawn(newWin.gBrowser.selectedBrowser, XHR_URL, function(url) {
+ return new Promise(resolve => {
+ var xhr = new content.window.XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.onload = () => resolve();
+ xhr.send();
+ });
+ });
+ checkCounts({totalURIs: 2, domainCount: 1, totalUnfilteredURIs: 2});
+
+ // Check that we're counting page fragments.
+ let loadingStopped = browserLocationChanged(newWin.gBrowser.selectedBrowser);
+ yield BrowserTestUtils.loadURI(newWin.gBrowser.selectedBrowser, "http://example.com/#2");
+ yield loadingStopped;
+ checkCounts({totalURIs: 3, domainCount: 1, totalUnfilteredURIs: 3});
+
+ // Check that a different URI from the example.com domain doesn't increment the unique count.
+ yield BrowserTestUtils.loadURI(newWin.gBrowser.selectedBrowser, "http://test1.example.com/");
+ yield BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({totalURIs: 4, domainCount: 1, totalUnfilteredURIs: 4});
+
+ // Make sure that the unique domains counter is incrementing for a different domain.
+ yield BrowserTestUtils.loadURI(newWin.gBrowser.selectedBrowser, "https://example.org/");
+ yield BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({totalURIs: 5, domainCount: 2, totalUnfilteredURIs: 5});
+
+ // Check that we only account for top level loads (e.g. we don't count URIs from
+ // embedded iframes).
+ yield ContentTask.spawn(newWin.gBrowser.selectedBrowser, null, function* () {
+ let doc = content.document;
+ let iframe = doc.createElement("iframe");
+ let promiseIframeLoaded = ContentTaskUtils.waitForEvent(iframe, "load", false);
+ iframe.src = "https://example.org/test";
+ doc.body.insertBefore(iframe, doc.body.firstChild);
+ yield promiseIframeLoaded;
+ });
+ checkCounts({totalURIs: 5, domainCount: 2, totalUnfilteredURIs: 5});
+
+ // Check that uncommon protocols get counted in the unfiltered URI probe.
+ const TEST_PAGE =
+ "data:text/html,<a id='target' href='%23par1'>Click me</a><a name='par1'>The paragraph.</a>";
+ yield BrowserTestUtils.loadURI(newWin.gBrowser.selectedBrowser, TEST_PAGE);
+ yield BrowserTestUtils.browserLoaded(newWin.gBrowser.selectedBrowser);
+ checkCounts({totalURIs: 5, domainCount: 2, totalUnfilteredURIs: 6});
+
+ // Clean up.
+ yield BrowserTestUtils.removeTab(firstTab);
+ yield BrowserTestUtils.closeWindow(newWin);
+});
diff --git a/browser/modules/test/browser_UsageTelemetry_content.js b/browser/modules/test/browser_UsageTelemetry_content.js
new file mode 100644
index 000000000..35c6b5a6d
--- /dev/null
+++ b/browser/modules/test/browser_UsageTelemetry_content.js
@@ -0,0 +1,121 @@
+"use strict";
+
+const BASE_PROBE_NAME = "browser.engagement.navigation.";
+const SCALAR_CONTEXT_MENU = BASE_PROBE_NAME + "contextmenu";
+const SCALAR_ABOUT_NEWTAB = BASE_PROBE_NAME + "about_newtab";
+
+add_task(function* setup() {
+ // Create two new search engines. Mark one as the default engine, so
+ // the test don't crash. We need to engines for this test as the searchbar
+ // in content doesn't display the default search engine among the one-off engines.
+ Services.search.addEngineWithDetails("MozSearch", "", "mozalias", "", "GET",
+ "http://example.com/?q={searchTerms}");
+
+ Services.search.addEngineWithDetails("MozSearch2", "", "mozalias2", "", "GET",
+ "http://example.com/?q={searchTerms}");
+
+ // Make the first engine the default search engine.
+ let engineDefault = Services.search.getEngineByName("MozSearch");
+ let originalEngine = Services.search.currentEngine;
+ Services.search.currentEngine = engineDefault;
+
+ // Move the second engine at the beginning of the one-off list.
+ let engineOneOff = Services.search.getEngineByName("MozSearch2");
+ Services.search.moveEngine(engineOneOff, 0);
+
+ yield SpecialPowers.pushPrefEnv({"set": [
+ ["dom.select_events.enabled", true], // We want select events to be fired.
+ ["toolkit.telemetry.enabled", true] // And Extended Telemetry to be enabled.
+ ]});
+
+ // Make sure to restore the engine once we're done.
+ registerCleanupFunction(function* () {
+ Services.search.currentEngine = originalEngine;
+ Services.search.removeEngine(engineDefault);
+ Services.search.removeEngine(engineOneOff);
+ });
+});
+
+add_task(function* test_context_menu() {
+ // Let's reset the Telemetry data.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist = getSearchCountsHistogram();
+
+ // Open a new tab with a page containing some text.
+ let tab =
+ yield BrowserTestUtils.openNewForegroundTab(gBrowser, "data:text/plain;charset=utf8,test%20search");
+
+ info("Select all the text in the page.");
+ yield ContentTask.spawn(tab.linkedBrowser, "", function*() {
+ return new Promise(resolve => {
+ content.document.addEventListener("selectionchange", () => resolve(), { once: true });
+ content.document.getSelection().selectAllChildren(content.document.body);
+ });
+ });
+
+ info("Open the context menu.");
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouseAtCenter("body", { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser);
+ yield popupPromise;
+
+ info("Click on search.");
+ let searchItem = contextMenu.getElementsByAttribute("id", "context-searchselect")[0];
+ searchItem.click();
+
+ info("Validate the search metrics.");
+ const scalars =
+ Services.telemetry.snapshotKeyedScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ checkKeyedScalar(scalars, SCALAR_CONTEXT_MENU, "search", 1);
+ Assert.equal(Object.keys(scalars[SCALAR_CONTEXT_MENU]).length, 1,
+ "This search must only increment one entry in the scalar.");
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ checkKeyedHistogram(search_hist, 'other-MozSearch.contextmenu', 1);
+
+ // Also check events.
+ let events = Services.telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ events = events.filter(e => e[1] == "navigation" && e[2] == "search");
+ checkEvents(events, [["navigation", "search", "contextmenu", null, {engine: "other-MozSearch"}]]);
+
+ contextMenu.hidePopup();
+ yield BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ yield BrowserTestUtils.removeTab(tab);
+});
+
+add_task(function* test_about_newtab() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist = getSearchCountsHistogram();
+
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:newtab", false);
+ yield ContentTask.spawn(tab.linkedBrowser, null, function* () {
+ yield ContentTaskUtils.waitForCondition(() => !content.document.hidden);
+ });
+
+ info("Trigger a simple serch, just text + enter.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ yield typeInSearchField(tab.linkedBrowser, "test query", "newtab-search-text");
+ yield BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser);
+ yield p;
+
+ // Check if the scalars contain the expected values.
+ const scalars =
+ Services.telemetry.snapshotKeyedScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ checkKeyedScalar(scalars, SCALAR_ABOUT_NEWTAB, "search_enter", 1);
+ Assert.equal(Object.keys(scalars[SCALAR_ABOUT_NEWTAB]).length, 1,
+ "This search must only increment one entry in the scalar.");
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ checkKeyedHistogram(search_hist, 'other-MozSearch.newtab', 1);
+
+ // Also check events.
+ let events = Services.telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ events = events.filter(e => e[1] == "navigation" && e[2] == "search");
+ checkEvents(events, [["navigation", "search", "about_newtab", "enter", {engine: "other-MozSearch"}]]);
+
+ yield BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/modules/test/browser_UsageTelemetry_content_aboutHome.js b/browser/modules/test/browser_UsageTelemetry_content_aboutHome.js
new file mode 100644
index 000000000..1818ae5fd
--- /dev/null
+++ b/browser/modules/test/browser_UsageTelemetry_content_aboutHome.js
@@ -0,0 +1,84 @@
+"use strict";
+
+const SCALAR_ABOUT_HOME = "browser.engagement.navigation.about_home";
+
+add_task(function* setup() {
+ // about:home uses IndexedDB. However, the test finishes too quickly and doesn't
+ // allow it enougth time to save. So it throws. This disables all the uncaught
+ // exception in this file and that's the reason why we split about:home tests
+ // out of the other UsageTelemetry files.
+ ignoreAllUncaughtExceptions();
+
+ // Create two new search engines. Mark one as the default engine, so
+ // the test don't crash. We need to engines for this test as the searchbar
+ // in content doesn't display the default search engine among the one-off engines.
+ Services.search.addEngineWithDetails("MozSearch", "", "mozalias", "", "GET",
+ "http://example.com/?q={searchTerms}");
+
+ Services.search.addEngineWithDetails("MozSearch2", "", "mozalias2", "", "GET",
+ "http://example.com/?q={searchTerms}");
+
+ // Make the first engine the default search engine.
+ let engineDefault = Services.search.getEngineByName("MozSearch");
+ let originalEngine = Services.search.currentEngine;
+ Services.search.currentEngine = engineDefault;
+
+ // Move the second engine at the beginning of the one-off list.
+ let engineOneOff = Services.search.getEngineByName("MozSearch2");
+ Services.search.moveEngine(engineOneOff, 0);
+
+ // Enable Extended Telemetry.
+ yield SpecialPowers.pushPrefEnv({"set": [["toolkit.telemetry.enabled", true]]});
+
+ // Make sure to restore the engine once we're done.
+ registerCleanupFunction(function* () {
+ Services.search.currentEngine = originalEngine;
+ Services.search.removeEngine(engineDefault);
+ Services.search.removeEngine(engineOneOff);
+ });
+});
+
+add_task(function* test_abouthome_simpleQuery() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist = getSearchCountsHistogram();
+
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ info("Setup waiting for AboutHomeLoadSnippetsCompleted.");
+ let promiseAboutHomeLoaded = new Promise(resolve => {
+ tab.linkedBrowser.addEventListener("AboutHomeLoadSnippetsCompleted", function loadListener(event) {
+ tab.linkedBrowser.removeEventListener("AboutHomeLoadSnippetsCompleted", loadListener, true);
+ resolve();
+ }, true, true);
+ });
+
+ info("Load about:home.");
+ tab.linkedBrowser.loadURI("about:home");
+ info("Wait for AboutHomeLoadSnippetsCompleted.");
+ yield promiseAboutHomeLoaded;
+
+ info("Trigger a simple serch, just test + enter.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ yield typeInSearchField(tab.linkedBrowser, "test query", "searchText");
+ yield BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser);
+ yield p;
+
+ // Check if the scalars contain the expected values.
+ const scalars =
+ Services.telemetry.snapshotKeyedScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ checkKeyedScalar(scalars, SCALAR_ABOUT_HOME, "search_enter", 1);
+ Assert.equal(Object.keys(scalars[SCALAR_ABOUT_HOME]).length, 1,
+ "This search must only increment one entry in the scalar.");
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ checkKeyedHistogram(search_hist, 'other-MozSearch.abouthome', 1);
+
+ // Also check events.
+ let events = Services.telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ events = events.filter(e => e[1] == "navigation" && e[2] == "search");
+ checkEvents(events, [["navigation", "search", "about_home", "enter", {engine: "other-MozSearch"}]]);
+
+ yield BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/modules/test/browser_UsageTelemetry_private_and_restore.js b/browser/modules/test/browser_UsageTelemetry_private_and_restore.js
new file mode 100644
index 000000000..144a4a03f
--- /dev/null
+++ b/browser/modules/test/browser_UsageTelemetry_private_and_restore.js
@@ -0,0 +1,90 @@
+"use strict";
+
+const MAX_CONCURRENT_TABS = "browser.engagement.max_concurrent_tab_count";
+const TAB_EVENT_COUNT = "browser.engagement.tab_open_event_count";
+const MAX_CONCURRENT_WINDOWS = "browser.engagement.max_concurrent_window_count";
+const WINDOW_OPEN_COUNT = "browser.engagement.window_open_event_count";
+const TOTAL_URI_COUNT = "browser.engagement.total_uri_count";
+const UNFILTERED_URI_COUNT = "browser.engagement.unfiltered_uri_count";
+const UNIQUE_DOMAINS_COUNT = "browser.engagement.unique_domains_count";
+
+function promiseBrowserStateRestored() {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(aSubject, aTopic) {
+ Services.obs.removeObserver(observer, "sessionstore-browser-state-restored");
+ resolve();
+ }, "sessionstore-browser-state-restored", false);
+ });
+}
+
+add_task(function* test_privateMode() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ // Open a private window and load a website in it.
+ let privateWin = yield BrowserTestUtils.openNewBrowserWindow({private: true});
+ yield BrowserTestUtils.loadURI(privateWin.gBrowser.selectedBrowser, "http://example.com/");
+ yield BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser);
+
+ // Check that tab and window count is recorded.
+ const scalars =
+ Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+
+ ok(!(TOTAL_URI_COUNT in scalars), "We should not track URIs in private mode.");
+ ok(!(UNFILTERED_URI_COUNT in scalars), "We should not track URIs in private mode.");
+ ok(!(UNIQUE_DOMAINS_COUNT in scalars), "We should not track unique domains in private mode.");
+ is(scalars[TAB_EVENT_COUNT], 1, "The number of open tab event count must match the expected value.");
+ is(scalars[MAX_CONCURRENT_TABS], 2, "The maximum tab count must match the expected value.");
+ is(scalars[WINDOW_OPEN_COUNT], 1, "The number of window open event count must match the expected value.");
+ is(scalars[MAX_CONCURRENT_WINDOWS], 2, "The maximum window count must match the expected value.");
+
+ // Clean up.
+ yield BrowserTestUtils.closeWindow(privateWin);
+});
+
+add_task(function* test_sessionRestore() {
+ const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand";
+ Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, false);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND);
+ });
+
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+
+ // The first window will be put into the already open window and the second
+ // window will be opened with _openWindowWithState, which is the source of the problem.
+ const state = {
+ windows: [
+ {
+ tabs: [
+ { entries: [{ url: "http://example.org" }], extData: { "uniq": 3785 } }
+ ],
+ selected: 1
+ }
+ ]
+ };
+
+ // Save the current session.
+ let SessionStore =
+ Cu.import("resource:///modules/sessionstore/SessionStore.jsm", {}).SessionStore;
+
+ // Load the custom state and wait for SSTabRestored, as we want to make sure
+ // that the URI counting code was hit.
+ let tabRestored = BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "SSTabRestored");
+ SessionStore.setBrowserState(JSON.stringify(state));
+ yield tabRestored;
+
+ // Check that the URI is not recorded.
+ const scalars =
+ Services.telemetry.snapshotScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN);
+
+ ok(!(TOTAL_URI_COUNT in scalars), "We should not track URIs from restored sessions.");
+ ok(!(UNFILTERED_URI_COUNT in scalars), "We should not track URIs from restored sessions.");
+ ok(!(UNIQUE_DOMAINS_COUNT in scalars), "We should not track unique domains from restored sessions.");
+
+ // Restore the original session and cleanup.
+ let sessionRestored = promiseBrowserStateRestored();
+ SessionStore.setBrowserState(JSON.stringify(state));
+ yield sessionRestored;
+});
diff --git a/browser/modules/test/browser_UsageTelemetry_searchbar.js b/browser/modules/test/browser_UsageTelemetry_searchbar.js
new file mode 100644
index 000000000..8aa3ceaee
--- /dev/null
+++ b/browser/modules/test/browser_UsageTelemetry_searchbar.js
@@ -0,0 +1,195 @@
+"use strict";
+
+const SCALAR_SEARCHBAR = "browser.engagement.navigation.searchbar";
+
+let searchInSearchbar = Task.async(function* (inputText) {
+ let win = window;
+ yield new Promise(r => waitForFocus(r, win));
+ let sb = BrowserSearch.searchBar;
+ // Write the search query in the searchbar.
+ sb.focus();
+ sb.value = inputText;
+ sb.textbox.controller.startSearch(inputText);
+ // Wait for the popup to show.
+ yield BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown");
+ // And then for the search to complete.
+ yield BrowserTestUtils.waitForCondition(() => sb.textbox.controller.searchStatus >=
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH,
+ "The search in the searchbar must complete.");
+});
+
+/**
+ * Click one of the entries in the search suggestion popup.
+ *
+ * @param {String} entryName
+ * The name of the elemet to click on.
+ */
+function clickSearchbarSuggestion(entryName) {
+ let popup = BrowserSearch.searchBar.textbox.popup;
+ let column = popup.tree.columns[0];
+
+ for (let rowID = 0; rowID < popup.tree.view.rowCount; rowID++) {
+ const suggestion = popup.tree.view.getValueAt(rowID, column);
+ if (suggestion !== entryName) {
+ continue;
+ }
+
+ // Make sure the suggestion is visible, just in case.
+ let tbo = popup.tree.treeBoxObject;
+ tbo.ensureRowIsVisible(rowID);
+ // Calculate the click coordinates.
+ let rect = tbo.getCoordsForCellItem(rowID, column, "text");
+ let x = rect.x + rect.width / 2;
+ let y = rect.y + rect.height / 2;
+ // Simulate the click.
+ EventUtils.synthesizeMouse(popup.tree.body, x, y, {},
+ popup.tree.ownerGlobal);
+ break;
+ }
+}
+
+add_task(function* setup() {
+ // Create two new search engines. Mark one as the default engine, so
+ // the test don't crash. We need to engines for this test as the searchbar
+ // doesn't display the default search engine among the one-off engines.
+ Services.search.addEngineWithDetails("MozSearch", "", "mozalias", "", "GET",
+ "http://example.com/?q={searchTerms}");
+
+ Services.search.addEngineWithDetails("MozSearch2", "", "mozalias2", "", "GET",
+ "http://example.com/?q={searchTerms}");
+
+ // Make the first engine the default search engine.
+ let engineDefault = Services.search.getEngineByName("MozSearch");
+ let originalEngine = Services.search.currentEngine;
+ Services.search.currentEngine = engineDefault;
+
+ // Move the second engine at the beginning of the one-off list.
+ let engineOneOff = Services.search.getEngineByName("MozSearch2");
+ Services.search.moveEngine(engineOneOff, 0);
+
+ // Enable Extended Telemetry.
+ yield SpecialPowers.pushPrefEnv({"set": [["toolkit.telemetry.enabled", true]]});
+
+ // Make sure to restore the engine once we're done.
+ registerCleanupFunction(function* () {
+ Services.search.currentEngine = originalEngine;
+ Services.search.removeEngine(engineDefault);
+ Services.search.removeEngine(engineOneOff);
+ });
+});
+
+add_task(function* test_plainQuery() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist = getSearchCountsHistogram();
+
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+
+ info("Simulate entering a simple search.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ yield searchInSearchbar("simple query");
+ EventUtils.sendKey("return");
+ yield p;
+
+ // Check if the scalars contain the expected values.
+ const scalars =
+ Services.telemetry.snapshotKeyedScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ checkKeyedScalar(scalars, SCALAR_SEARCHBAR, "search_enter", 1);
+ Assert.equal(Object.keys(scalars[SCALAR_SEARCHBAR]).length, 1,
+ "This search must only increment one entry in the scalar.");
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ checkKeyedHistogram(search_hist, 'other-MozSearch.searchbar', 1);
+
+ // Also check events.
+ let events = Services.telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ events = events.filter(e => e[1] == "navigation" && e[2] == "search");
+ checkEvents(events, [["navigation", "search", "searchbar", "enter", {engine: "other-MozSearch"}]]);
+
+ yield BrowserTestUtils.removeTab(tab);
+});
+
+add_task(function* test_oneOff() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist = getSearchCountsHistogram();
+
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+
+ info("Perform a one-off search using the first engine.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ yield searchInSearchbar("query");
+
+ info("Pressing Alt+Down to highlight the first one off engine.");
+ EventUtils.synthesizeKey("VK_DOWN", { altKey: true });
+ EventUtils.sendKey("return");
+ yield p;
+
+ // Check if the scalars contain the expected values.
+ const scalars =
+ Services.telemetry.snapshotKeyedScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ checkKeyedScalar(scalars, SCALAR_SEARCHBAR, "search_oneoff", 1);
+ Assert.equal(Object.keys(scalars[SCALAR_SEARCHBAR]).length, 1,
+ "This search must only increment one entry in the scalar.");
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ checkKeyedHistogram(search_hist, 'other-MozSearch2.searchbar', 1);
+
+ // Also check events.
+ let events = Services.telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ events = events.filter(e => e[1] == "navigation" && e[2] == "search");
+ checkEvents(events, [["navigation", "search", "searchbar", "oneoff", {engine: "other-MozSearch2"}]]);
+
+ yield BrowserTestUtils.removeTab(tab);
+});
+
+add_task(function* test_suggestion() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist = getSearchCountsHistogram();
+
+ // Create an engine to generate search suggestions and add it as default
+ // for this test.
+ const url = getRootDirectory(gTestPath) + "usageTelemetrySearchSuggestions.xml";
+ let suggestionEngine = yield new Promise((resolve, reject) => {
+ Services.search.addEngine(url, null, "", false, {
+ onSuccess(engine) { resolve(engine) },
+ onError() { reject() }
+ });
+ });
+
+ let previousEngine = Services.search.currentEngine;
+ Services.search.currentEngine = suggestionEngine;
+
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+
+ info("Perform a one-off search using the first engine.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ yield searchInSearchbar("query");
+ info("Clicking the searchbar suggestion.");
+ clickSearchbarSuggestion("queryfoo");
+ yield p;
+
+ // Check if the scalars contain the expected values.
+ const scalars =
+ Services.telemetry.snapshotKeyedScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ checkKeyedScalar(scalars, SCALAR_SEARCHBAR, "search_suggestion", 1);
+ Assert.equal(Object.keys(scalars[SCALAR_SEARCHBAR]).length, 1,
+ "This search must only increment one entry in the scalar.");
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ let searchEngineId = 'other-' + suggestionEngine.name;
+ checkKeyedHistogram(search_hist, searchEngineId + '.searchbar', 1);
+
+ // Also check events.
+ let events = Services.telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ events = events.filter(e => e[1] == "navigation" && e[2] == "search");
+ checkEvents(events, [["navigation", "search", "searchbar", "suggestion", {engine: searchEngineId}]]);
+
+ Services.search.currentEngine = previousEngine;
+ Services.search.removeEngine(suggestionEngine);
+ yield BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/modules/test/browser_UsageTelemetry_urlbar.js b/browser/modules/test/browser_UsageTelemetry_urlbar.js
new file mode 100644
index 000000000..81d3e28ba
--- /dev/null
+++ b/browser/modules/test/browser_UsageTelemetry_urlbar.js
@@ -0,0 +1,220 @@
+"use strict";
+
+const SCALAR_URLBAR = "browser.engagement.navigation.urlbar";
+
+// The preference to enable suggestions in the urlbar.
+const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches";
+// The name of the search engine used to generate suggestions.
+const SUGGESTION_ENGINE_NAME = "browser_UsageTelemetry usageTelemetrySearchSuggestions.xml";
+const ONEOFF_URLBAR_PREF = "browser.urlbar.oneOffSearches";
+
+let searchInAwesomebar = Task.async(function* (inputText, win=window) {
+ yield new Promise(r => waitForFocus(r, win));
+ // Write the search query in the urlbar.
+ win.gURLBar.focus();
+ win.gURLBar.value = inputText;
+ win.gURLBar.controller.startSearch(inputText);
+ // Wait for the popup to show.
+ yield BrowserTestUtils.waitForEvent(win.gURLBar.popup, "popupshown");
+ // And then for the search to complete.
+ yield BrowserTestUtils.waitForCondition(() => win.gURLBar.controller.searchStatus >=
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH);
+});
+
+/**
+ * Click one of the entries in the urlbar suggestion popup.
+ *
+ * @param {String} entryName
+ * The name of the elemet to click on.
+ */
+function clickURLBarSuggestion(entryName) {
+ // The entry in the suggestion list should follow the format:
+ // "<search term> <engine name> Search"
+ const expectedSuggestionName = entryName + " " + SUGGESTION_ENGINE_NAME + " Search";
+ for (let child of gURLBar.popup.richlistbox.children) {
+ if (child.label === expectedSuggestionName) {
+ // This entry is the search suggestion we're looking for.
+ child.click();
+ return;
+ }
+ }
+}
+
+add_task(function* setup() {
+ // Create a new search engine.
+ Services.search.addEngineWithDetails("MozSearch", "", "mozalias", "", "GET",
+ "http://example.com/?q={searchTerms}");
+
+ // Make it the default search engine.
+ let engine = Services.search.getEngineByName("MozSearch");
+ let originalEngine = Services.search.currentEngine;
+ Services.search.currentEngine = engine;
+
+ // And the first one-off engine.
+ Services.search.moveEngine(engine, 0);
+
+ // Enable search suggestions in the urlbar.
+ Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, true);
+
+ // Enable the urlbar one-off buttons.
+ Services.prefs.setBoolPref(ONEOFF_URLBAR_PREF, true);
+
+ // Enable Extended Telemetry.
+ yield SpecialPowers.pushPrefEnv({"set": [["toolkit.telemetry.enabled", true]]});
+
+ // Make sure to restore the engine once we're done.
+ registerCleanupFunction(function* () {
+ Services.search.currentEngine = originalEngine;
+ Services.search.removeEngine(engine);
+ Services.prefs.clearUserPref(SUGGEST_URLBAR_PREF, true);
+ Services.prefs.clearUserPref(ONEOFF_URLBAR_PREF);
+ });
+});
+
+add_task(function* test_simpleQuery() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist = getSearchCountsHistogram();
+
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+
+ info("Simulate entering a simple search.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ yield searchInAwesomebar("simple query");
+ EventUtils.sendKey("return");
+ yield p;
+
+ // Check if the scalars contain the expected values.
+ const scalars =
+ Services.telemetry.snapshotKeyedScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ checkKeyedScalar(scalars, SCALAR_URLBAR, "search_enter", 1);
+ Assert.equal(Object.keys(scalars[SCALAR_URLBAR]).length, 1,
+ "This search must only increment one entry in the scalar.");
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ checkKeyedHistogram(search_hist, 'other-MozSearch.urlbar', 1);
+
+ // Also check events.
+ let events = Services.telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ events = events.filter(e => e[1] == "navigation" && e[2] == "search");
+ checkEvents(events, [["navigation", "search", "urlbar", "enter", {engine: "other-MozSearch"}]]);
+
+ yield BrowserTestUtils.removeTab(tab);
+});
+
+add_task(function* test_searchAlias() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist = getSearchCountsHistogram();
+
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+
+ info("Search using a search alias.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ yield searchInAwesomebar("mozalias query");
+ EventUtils.sendKey("return");
+ yield p;
+
+ // Check if the scalars contain the expected values.
+ const scalars =
+ Services.telemetry.snapshotKeyedScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ checkKeyedScalar(scalars, SCALAR_URLBAR, "search_alias", 1);
+ Assert.equal(Object.keys(scalars[SCALAR_URLBAR]).length, 1,
+ "This search must only increment one entry in the scalar.");
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ checkKeyedHistogram(search_hist, 'other-MozSearch.urlbar', 1);
+
+ // Also check events.
+ let events = Services.telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ events = events.filter(e => e[1] == "navigation" && e[2] == "search");
+ checkEvents(events, [["navigation", "search", "urlbar", "alias", {engine: "other-MozSearch"}]]);
+
+ yield BrowserTestUtils.removeTab(tab);
+});
+
+add_task(function* test_oneOff() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist = getSearchCountsHistogram();
+
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+
+ info("Perform a one-off search using the first engine.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ yield searchInAwesomebar("query");
+
+ info("Pressing Alt+Down to take us to the first one-off engine.");
+ EventUtils.synthesizeKey("VK_DOWN", { altKey: true });
+ EventUtils.sendKey("return");
+ yield p;
+
+ // Check if the scalars contain the expected values.
+ const scalars =
+ Services.telemetry.snapshotKeyedScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ checkKeyedScalar(scalars, SCALAR_URLBAR, "search_oneoff", 1);
+ Assert.equal(Object.keys(scalars[SCALAR_URLBAR]).length, 1,
+ "This search must only increment one entry in the scalar.");
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ checkKeyedHistogram(search_hist, 'other-MozSearch.urlbar', 1);
+
+ // Also check events.
+ let events = Services.telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ events = events.filter(e => e[1] == "navigation" && e[2] == "search");
+ checkEvents(events, [["navigation", "search", "urlbar", "oneoff", {engine: "other-MozSearch"}]]);
+
+ yield BrowserTestUtils.removeTab(tab);
+});
+
+add_task(function* test_suggestion() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist = getSearchCountsHistogram();
+
+ // Create an engine to generate search suggestions and add it as default
+ // for this test.
+ const url = getRootDirectory(gTestPath) + "usageTelemetrySearchSuggestions.xml";
+ let suggestionEngine = yield new Promise((resolve, reject) => {
+ Services.search.addEngine(url, null, "", false, {
+ onSuccess(engine) { resolve(engine) },
+ onError() { reject() }
+ });
+ });
+
+ let previousEngine = Services.search.currentEngine;
+ Services.search.currentEngine = suggestionEngine;
+
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+
+ info("Perform a one-off search using the first engine.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ yield searchInAwesomebar("query");
+ info("Clicking the urlbar suggestion.");
+ clickURLBarSuggestion("queryfoo");
+ yield p;
+
+ // Check if the scalars contain the expected values.
+ const scalars =
+ Services.telemetry.snapshotKeyedScalars(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ checkKeyedScalar(scalars, SCALAR_URLBAR, "search_suggestion", 1);
+ Assert.equal(Object.keys(scalars[SCALAR_URLBAR]).length, 1,
+ "This search must only increment one entry in the scalar.");
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ let searchEngineId = 'other-' + suggestionEngine.name;
+ checkKeyedHistogram(search_hist, searchEngineId + '.urlbar', 1);
+
+ // Also check events.
+ let events = Services.telemetry.snapshotBuiltinEvents(Ci.nsITelemetry.DATASET_RELEASE_CHANNEL_OPTIN, false);
+ events = events.filter(e => e[1] == "navigation" && e[2] == "search");
+ checkEvents(events, [["navigation", "search", "urlbar", "suggestion", {engine: searchEngineId}]]);
+
+ Services.search.currentEngine = previousEngine;
+ Services.search.removeEngine(suggestionEngine);
+ yield BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/modules/test/browser_taskbar_preview.js b/browser/modules/test/browser_taskbar_preview.js
new file mode 100644
index 000000000..89295b9e0
--- /dev/null
+++ b/browser/modules/test/browser_taskbar_preview.js
@@ -0,0 +1,100 @@
+function test() {
+ var isWin7OrHigher = false;
+ try {
+ let version = Cc["@mozilla.org/system-info;1"]
+ .getService(Ci.nsIPropertyBag2)
+ .getProperty("version");
+ isWin7OrHigher = (parseFloat(version) >= 6.1);
+ } catch (ex) { }
+
+ is(!!Win7Features, isWin7OrHigher, "Win7Features available when it should be");
+ if (!isWin7OrHigher)
+ return;
+
+ const ENABLE_PREF_NAME = "browser.taskbar.previews.enable";
+
+ let temp = {};
+ Cu.import("resource:///modules/WindowsPreviewPerTab.jsm", temp);
+ let AeroPeek = temp.AeroPeek;
+
+ waitForExplicitFinish();
+
+ gPrefService.setBoolPref(ENABLE_PREF_NAME, true);
+
+ is(1, AeroPeek.windows.length, "Got the expected number of windows");
+
+ checkPreviews(1, "Browser starts with one preview");
+
+ gBrowser.addTab();
+ gBrowser.addTab();
+ gBrowser.addTab();
+
+ checkPreviews(4, "Correct number of previews after adding");
+
+ for (let preview of AeroPeek.previews)
+ ok(preview.visible, "Preview is shown as expected");
+
+ gPrefService.setBoolPref(ENABLE_PREF_NAME, false);
+ is(0, AeroPeek.previews.length, "Should have 0 previews when disabled");
+
+ gPrefService.setBoolPref(ENABLE_PREF_NAME, true);
+ checkPreviews(4, "Previews are back when re-enabling");
+ for (let preview of AeroPeek.previews)
+ ok(preview.visible, "Preview is shown as expected after re-enabling");
+
+ [1, 2, 3, 4].forEach(function (idx) {
+ gBrowser.selectedTab = gBrowser.tabs[idx];
+ ok(checkSelectedTab(), "Current tab is correctly selected");
+ });
+
+ // Close #4
+ getPreviewForTab(gBrowser.selectedTab).controller.onClose();
+ checkPreviews(3, "Expected number of previews after closing selected tab via controller");
+ ok(gBrowser.tabs.length == 3, "Successfully closed a tab");
+
+ // Select #1
+ ok(getPreviewForTab(gBrowser.tabs[0]).controller.onActivate(), "Activation was accepted");
+ ok(gBrowser.tabs[0].selected, "Correct tab was selected");
+ checkSelectedTab();
+
+ // Remove #3 (non active)
+ gBrowser.removeTab(gBrowser.tabContainer.lastChild);
+ checkPreviews(2, "Expected number of previews after closing unselected via browser");
+
+ // Remove #1 (active)
+ gBrowser.removeTab(gBrowser.tabContainer.firstChild);
+ checkPreviews(1, "Expected number of previews after closing selected tab via browser");
+
+ // Add a new tab
+ gBrowser.addTab();
+ checkPreviews(2);
+ // Check default selection
+ checkSelectedTab();
+
+ // Change selection
+ gBrowser.selectedTab = gBrowser.tabs[0];
+ checkSelectedTab();
+ // Close nonselected tab via controller
+ getPreviewForTab(gBrowser.tabs[1]).controller.onClose();
+ checkPreviews(1);
+
+ if (gPrefService.prefHasUserValue(ENABLE_PREF_NAME))
+ gPrefService.setBoolPref(ENABLE_PREF_NAME, !gPrefService.getBoolPref(ENABLE_PREF_NAME));
+
+ finish();
+
+ function checkPreviews(aPreviews, msg) {
+ let nPreviews = AeroPeek.previews.length;
+ is(aPreviews, gBrowser.tabs.length, "Browser has expected number of tabs - " + msg);
+ is(nPreviews, gBrowser.tabs.length, "Browser has one preview per tab - " + msg);
+ is(nPreviews, aPreviews, msg || "Got expected number of previews");
+ }
+
+ function getPreviewForTab(tab) {
+ return window.gTaskbarTabGroup.previewFromTab(tab);
+ }
+
+ function checkSelectedTab() {
+ return getPreviewForTab(gBrowser.selectedTab).active;
+ }
+}
diff --git a/browser/modules/test/browser_urlBar_zoom.js b/browser/modules/test/browser_urlBar_zoom.js
new file mode 100644
index 000000000..9cb5c96c6
--- /dev/null
+++ b/browser/modules/test/browser_urlBar_zoom.js
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var initialPageZoom = ZoomManager.zoom;
+const kTimeoutInMS = 20000;
+
+add_task(function* () {
+ info("Confirm whether the browser zoom is set to the default level");
+ is(initialPageZoom, 1, "Page zoom is set to default (100%)");
+ let zoomResetButton = document.getElementById("urlbar-zoom-button");
+ is(zoomResetButton.hidden, true, "Zoom reset button is currently hidden");
+
+ info("Change zoom and confirm zoom button appears");
+ let labelUpdatePromise = BrowserTestUtils.waitForAttribute("label", zoomResetButton);
+ FullZoom.enlarge();
+ yield labelUpdatePromise;
+ info("Zoom increased to " + Math.floor(ZoomManager.zoom * 100) + "%");
+ is(zoomResetButton.hidden, false, "Zoom reset button is now visible");
+ let pageZoomLevel = Math.floor(ZoomManager.zoom * 100);
+ let expectedZoomLevel = 110;
+ let buttonZoomLevel = parseInt(zoomResetButton.getAttribute("label"), 10);
+ is(buttonZoomLevel, expectedZoomLevel, ("Button label updated successfully to " + Math.floor(ZoomManager.zoom * 100) + "%"));
+
+ let zoomResetPromise = promiseObserverNotification("browser-fullZoom:zoomReset");
+ zoomResetButton.click();
+ yield zoomResetPromise;
+ pageZoomLevel = Math.floor(ZoomManager.zoom * 100);
+ expectedZoomLevel = 100;
+ is(pageZoomLevel, expectedZoomLevel, "Clicking zoom button successfully resets browser zoom to 100%");
+ is(zoomResetButton.hidden, true, "Zoom reset button returns to being hidden");
+
+});
+
+add_task(function* () {
+ info("Confirm that URL bar zoom button doesn't appear when customizable zoom widget is added to toolbar");
+ CustomizableUI.addWidgetToArea("zoom-controls", CustomizableUI.AREA_NAVBAR);
+ let zoomCustomizableWidget = document.getElementById("zoom-reset-button");
+ let zoomResetButton = document.getElementById("urlbar-zoom-button");
+ let zoomChangePromise = promiseObserverNotification("browser-fullZoom:zoomChange");
+ FullZoom.enlarge();
+ yield zoomChangePromise;
+ is(zoomResetButton.hidden, true, "URL zoom button remains hidden despite zoom increase");
+ is(parseInt(zoomCustomizableWidget.label, 10), 110, "Customizable zoom widget's label has updated to " + zoomCustomizableWidget.label);
+});
+
+add_task(function* asyncCleanup() {
+ // reset zoom level and customizable widget
+ ZoomManager.zoom = initialPageZoom;
+ is(ZoomManager.zoom, 1, "Zoom level was restored");
+ if (document.getElementById("zoom-controls")) {
+ CustomizableUI.removeWidgetFromArea("zoom-controls", CustomizableUI.AREA_NAVBAR);
+ ok(!document.getElementById("zoom-controls"), "Customizable zoom widget removed from toolbar");
+ }
+
+});
+
+function promiseObserverNotification(aObserver) {
+ let deferred = Promise.defer();
+ function notificationCallback(e) {
+ Services.obs.removeObserver(notificationCallback, aObserver, false);
+ clearTimeout(timeoutId);
+ deferred.resolve();
+ }
+ let timeoutId = setTimeout(() => {
+ Services.obs.removeObserver(notificationCallback, aObserver, false);
+ deferred.reject("Notification '" + aObserver + "' did not happen within 20 seconds.");
+ }, kTimeoutInMS);
+ Services.obs.addObserver(notificationCallback, aObserver, false);
+ return deferred.promise;
+}
diff --git a/browser/modules/test/contentSearch.js b/browser/modules/test/contentSearch.js
new file mode 100644
index 000000000..b5dddfe45
--- /dev/null
+++ b/browser/modules/test/contentSearch.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TEST_MSG = "ContentSearchTest";
+const SERVICE_EVENT_TYPE = "ContentSearchService";
+const CLIENT_EVENT_TYPE = "ContentSearchClient";
+
+// Forward events from the in-content service to the test.
+content.addEventListener(SERVICE_EVENT_TYPE, event => {
+ // The event dispatch code in content.js clones the event detail into the
+ // content scope. That's generally the right thing, but causes us to end
+ // up with an XrayWrapper to it here, which will screw us up when trying to
+ // serialize the object in sendAsyncMessage. Waive Xrays for the benefit of
+ // the test machinery.
+ sendAsyncMessage(TEST_MSG, Components.utils.waiveXrays(event.detail));
+});
+
+// Forward messages from the test to the in-content service.
+addMessageListener(TEST_MSG, msg => {
+ // If the message is a search, stop the page from loading and then tell the
+ // test that it loaded.
+ if (msg.data.type == "Search") {
+ waitForLoadAndStopIt(msg.data.expectedURL, url => {
+ sendAsyncMessage(TEST_MSG, {
+ type: "loadStopped",
+ url: url,
+ });
+ });
+ }
+
+ content.dispatchEvent(
+ new content.CustomEvent(CLIENT_EVENT_TYPE, {
+ detail: msg.data,
+ })
+ );
+});
+
+function waitForLoadAndStopIt(expectedURL, callback) {
+ let Ci = Components.interfaces;
+ let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ let listener = {
+ onStateChange: function (webProg, req, flags, status) {
+ if (req instanceof Ci.nsIChannel) {
+ let url = req.originalURI.spec;
+ dump("waitForLoadAndStopIt: onStateChange " + url + "\n");
+ let docStart = Ci.nsIWebProgressListener.STATE_IS_DOCUMENT |
+ Ci.nsIWebProgressListener.STATE_START;
+ if ((flags & docStart) && webProg.isTopLevel && url == expectedURL) {
+ webProgress.removeProgressListener(listener);
+ req.cancel(Components.results.NS_ERROR_FAILURE);
+ callback(url);
+ }
+ }
+ },
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIWebProgressListener,
+ Ci.nsISupportsWeakReference,
+ ]),
+ };
+ webProgress.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_ALL);
+ dump("waitForLoadAndStopIt: Waiting for URL to load: " + expectedURL + "\n");
+}
diff --git a/browser/modules/test/contentSearchBadImage.xml b/browser/modules/test/contentSearchBadImage.xml
new file mode 100644
index 000000000..6e4cb60a5
--- /dev/null
+++ b/browser/modules/test/contentSearchBadImage.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_ContentSearch contentSearchBadImage.xml</ShortName>
+<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchBadImage" rel="searchform"/>
+<Image width="16" height="16">data:image/x-icon;base64,notbase64</Image>
+</SearchPlugin>
diff --git a/browser/modules/test/contentSearchSuggestions.sjs b/browser/modules/test/contentSearchSuggestions.sjs
new file mode 100644
index 000000000..1978b4f66
--- /dev/null
+++ b/browser/modules/test/contentSearchSuggestions.sjs
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(req, resp) {
+ let suffixes = ["foo", "bar"];
+ let data = [req.queryString, suffixes.map(s => req.queryString + s)];
+ resp.setHeader("Content-Type", "application/json", false);
+ resp.write(JSON.stringify(data));
+}
diff --git a/browser/modules/test/contentSearchSuggestions.xml b/browser/modules/test/contentSearchSuggestions.xml
new file mode 100644
index 000000000..81c23379c
--- /dev/null
+++ b/browser/modules/test/contentSearchSuggestions.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_ContentSearch contentSearchSuggestions.xml</ShortName>
+<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/modules/test/contentSearchSuggestions.sjs?{searchTerms}"/>
+<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchSuggestions" rel="searchform"/>
+</SearchPlugin>
diff --git a/browser/modules/test/head.js b/browser/modules/test/head.js
new file mode 100644
index 000000000..be0215156
--- /dev/null
+++ b/browser/modules/test/head.js
@@ -0,0 +1,113 @@
+Cu.import("resource://gre/modules/Promise.jsm");
+
+const SINGLE_TRY_TIMEOUT = 100;
+const NUMBER_OF_TRIES = 30;
+
+function waitForConditionPromise(condition, timeoutMsg, tryCount=NUMBER_OF_TRIES) {
+ let defer = Promise.defer();
+ let tries = 0;
+ function checkCondition() {
+ if (tries >= tryCount) {
+ defer.reject(timeoutMsg);
+ }
+ var conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ return defer.reject(e);
+ }
+ if (conditionPassed) {
+ return defer.resolve();
+ }
+ tries++;
+ setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
+ return undefined;
+ }
+ setTimeout(checkCondition, SINGLE_TRY_TIMEOUT);
+ return defer.promise;
+}
+
+function waitForCondition(condition, nextTest, errorMsg) {
+ waitForConditionPromise(condition, errorMsg).then(nextTest, (reason) => {
+ ok(false, reason + (reason.stack ? "\n" + reason.stack : ""));
+ });
+}
+
+/**
+ * Checks if the snapshotted keyed scalars contain the expected
+ * data.
+ *
+ * @param {Object} scalars
+ * The snapshot of the keyed scalars.
+ * @param {String} scalarName
+ * The name of the keyed scalar to check.
+ * @param {String} key
+ * The key that must be within the keyed scalar.
+ * @param {String|Boolean|Number} expectedValue
+ * The expected value for the provided key in the scalar.
+ */
+function checkKeyedScalar(scalars, scalarName, key, expectedValue) {
+ Assert.ok(scalarName in scalars,
+ scalarName + " must be recorded.");
+ Assert.ok(key in scalars[scalarName],
+ scalarName + " must contain the '" + key + "' key.");
+ Assert.ok(scalars[scalarName][key], expectedValue,
+ scalarName + "['" + key + "'] must contain the expected value");
+}
+
+/**
+ * An utility function to write some text in the search input box
+ * in a content page.
+ * @param {Object} browser
+ * The browser that contains the content.
+ * @param {String} text
+ * The string to write in the search field.
+ * @param {String} fieldName
+ * The name of the field to write to.
+ */
+let typeInSearchField = Task.async(function* (browser, text, fieldName) {
+ yield ContentTask.spawn(browser, { fieldName, text }, function* ({fieldName, text}) {
+ // Avoid intermittent failures.
+ if (fieldName === "searchText") {
+ content.wrappedJSObject.gContentSearchController.remoteTimeout = 5000;
+ }
+ // Put the focus on the search box.
+ let searchInput = content.document.getElementById(fieldName);
+ searchInput.focus();
+ searchInput.value = text;
+ });
+});
+
+/**
+ * Clear and get the SEARCH_COUNTS histogram.
+ */
+function getSearchCountsHistogram() {
+ let search_hist = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS");
+ search_hist.clear();
+ return search_hist;
+}
+
+/**
+ * Check that the keyed histogram contains the right value.
+ */
+function checkKeyedHistogram(h, key, expectedValue) {
+ const snapshot = h.snapshot();
+ Assert.ok(key in snapshot, `The histogram must contain ${key}.`);
+ Assert.equal(snapshot[key].sum, expectedValue, `The key ${key} must contain ${expectedValue}.`);
+}
+
+function checkEvents(events, expectedEvents) {
+ if (!Services.telemetry.canRecordExtended) {
+ // Currently we only collect the tested events when extended Telemetry is enabled.
+ return;
+ }
+
+ Assert.equal(events.length, expectedEvents.length, "Should have matching amount of events.");
+
+ // Strip timestamps from the events for easier comparison.
+ events = events.map(e => e.slice(1));
+
+ for (let i = 0; i < events.length; ++i) {
+ Assert.deepEqual(events[i], expectedEvents[i], "Events should match.");
+ }
+}
diff --git a/browser/modules/test/unit/social/.eslintrc.js b/browser/modules/test/unit/social/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/browser/modules/test/unit/social/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/browser/modules/test/unit/social/blocklist.xml b/browser/modules/test/unit/social/blocklist.xml
new file mode 100644
index 000000000..c8d72d624
--- /dev/null
+++ b/browser/modules/test/unit/social/blocklist.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem blockID="s1" id="bad.com@services.mozilla.org"></emItem>
+ </emItems>
+</blocklist>
diff --git a/browser/modules/test/unit/social/head.js b/browser/modules/test/unit/social/head.js
new file mode 100644
index 000000000..0beabb685
--- /dev/null
+++ b/browser/modules/test/unit/social/head.js
@@ -0,0 +1,210 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+var Social, SocialService;
+
+var manifests = [
+ {
+ name: "provider 1",
+ origin: "https://example1.com",
+ sidebarURL: "https://example1.com/sidebar/",
+ },
+ {
+ name: "provider 2",
+ origin: "https://example2.com",
+ sidebarURL: "https://example1.com/sidebar/",
+ }
+];
+
+const MANIFEST_PREFS = Services.prefs.getBranch("social.manifest.");
+
+// SocialProvider class relies on blocklisting being enabled. To enable
+// blocklisting, we have to setup an app and initialize the blocklist (see
+// initApp below).
+const gProfD = do_get_profile();
+
+function createAppInfo(ID, name, version, platformVersion="1.0") {
+ let tmp = {};
+ Cu.import("resource://testing-common/AppInfo.jsm", tmp);
+ tmp.updateAppInfo({
+ ID, name, version, platformVersion,
+ crashReporter: true,
+ });
+ gAppInfo = tmp.getAppInfo();
+}
+
+function initApp() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+ // prepare a blocklist file for the blocklist service
+ var blocklistFile = gProfD.clone();
+ blocklistFile.append("blocklist.xml");
+ if (blocklistFile.exists())
+ blocklistFile.remove(false);
+ var source = do_get_file("blocklist.xml");
+ source.copyTo(gProfD, "blocklist.xml");
+ blocklistFile.lastModifiedTime = Date.now();
+
+
+ let internalManager = Cc["@mozilla.org/addons/integration;1"].
+ getService(Ci.nsIObserver).
+ QueryInterface(Ci.nsITimerCallback);
+
+ internalManager.observe(null, "addons-startup", null);
+}
+
+function setManifestPref(manifest) {
+ let string = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ string.data = JSON.stringify(manifest);
+ Services.prefs.setComplexValue("social.manifest." + manifest.origin, Ci.nsISupportsString, string);
+}
+
+function do_wait_observer(obsTopic, cb) {
+ function observer(subject, topic, data) {
+ Services.obs.removeObserver(observer, topic);
+ cb();
+ }
+ Services.obs.addObserver(observer, obsTopic, false);
+}
+
+function do_add_providers(cb) {
+ // run only after social is already initialized
+ SocialService.addProvider(manifests[0], function() {
+ do_wait_observer("social:providers-changed", function() {
+ do_check_eq(Social.providers.length, 2, "2 providers installed");
+ do_execute_soon(cb);
+ });
+ SocialService.addProvider(manifests[1]);
+ });
+}
+
+function do_initialize_social(enabledOnStartup, cb) {
+ initApp();
+
+ if (enabledOnStartup) {
+ // set prefs before initializing social
+ manifests.forEach(function (manifest) {
+ setManifestPref(manifest);
+ });
+ // Set both providers active and flag the first one as "current"
+ let activeVal = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ let active = {};
+ for (let m of manifests)
+ active[m.origin] = 1;
+ activeVal.data = JSON.stringify(active);
+ Services.prefs.setComplexValue("social.activeProviders",
+ Ci.nsISupportsString, activeVal);
+
+ do_register_cleanup(function() {
+ manifests.forEach(function (manifest) {
+ Services.prefs.clearUserPref("social.manifest." + manifest.origin);
+ });
+ Services.prefs.clearUserPref("social.activeProviders");
+ });
+
+ // expecting 2 providers installed
+ do_wait_observer("social:providers-changed", function() {
+ do_check_eq(Social.providers.length, 2, "2 providers installed");
+ do_execute_soon(cb);
+ });
+ }
+
+ // import and initialize everything
+ SocialService = Cu.import("resource:///modules/SocialService.jsm", {}).SocialService;
+ do_check_eq(enabledOnStartup, SocialService.hasEnabledProviders, "Service has enabled providers");
+ Social = Cu.import("resource:///modules/Social.jsm", {}).Social;
+ do_check_false(Social.initialized, "Social is not initialized");
+ Social.init();
+ do_check_true(Social.initialized, "Social is initialized");
+ if (!enabledOnStartup)
+ do_execute_soon(cb);
+}
+
+function AsyncRunner() {
+ do_test_pending();
+ do_register_cleanup(() => this.destroy());
+
+ this._callbacks = {
+ done: do_test_finished,
+ error: function (err) {
+ // xpcshell test functions like do_check_eq throw NS_ERROR_ABORT on
+ // failure. Ignore those so they aren't rethrown here.
+ if (err !== Cr.NS_ERROR_ABORT) {
+ if (err.stack) {
+ err = err + " - See following stack:\n" + err.stack +
+ "\nUseless do_throw stack";
+ }
+ do_throw(err);
+ }
+ },
+ consoleError: function (scriptErr) {
+ // Try to ensure the error is related to the test.
+ let filename = scriptErr.sourceName || scriptErr.toString() || "";
+ if (filename.indexOf("/toolkit/components/social/") >= 0)
+ do_throw(scriptErr);
+ },
+ };
+ this._iteratorQueue = [];
+
+ // This catches errors reported to the console, e.g., via Cu.reportError, but
+ // not on the runner's stack.
+ Cc["@mozilla.org/consoleservice;1"].
+ getService(Ci.nsIConsoleService).
+ registerListener(this);
+}
+
+AsyncRunner.prototype = {
+
+ appendIterator: function appendIterator(iter) {
+ this._iteratorQueue.push(iter);
+ },
+
+ next: function next(arg) {
+ if (!this._iteratorQueue.length) {
+ this.destroy();
+ this._callbacks.done();
+ return;
+ }
+
+ try {
+ var { done, value: val } = this._iteratorQueue[0].next(arg);
+ if (done) {
+ this._iteratorQueue.shift();
+ this.next();
+ return;
+ }
+ }
+ catch (err) {
+ this._callbacks.error(err);
+ }
+
+ // val is an iterator => prepend it to the queue and start on it
+ // val is otherwise truthy => call next
+ if (val) {
+ if (typeof(val) != "boolean")
+ this._iteratorQueue.unshift(val);
+ this.next();
+ }
+ },
+
+ destroy: function destroy() {
+ Cc["@mozilla.org/consoleservice;1"].
+ getService(Ci.nsIConsoleService).
+ unregisterListener(this);
+ this.destroy = function alreadyDestroyed() {};
+ },
+
+ observe: function observe(msg) {
+ if (msg instanceof Ci.nsIScriptError &&
+ !(msg.flags & Ci.nsIScriptError.warningFlag))
+ {
+ this._callbacks.consoleError(msg);
+ }
+ },
+};
diff --git a/browser/modules/test/unit/social/test_SocialService.js b/browser/modules/test/unit/social/test_SocialService.js
new file mode 100644
index 000000000..e6f354fed
--- /dev/null
+++ b/browser/modules/test/unit/social/test_SocialService.js
@@ -0,0 +1,166 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils",
+ "resource://testing-common/PlacesTestUtils.jsm");
+
+function run_test() {
+ initApp();
+
+ // NOTE: none of the manifests here can have a workerURL set, or we attempt
+ // to create a FrameWorker and that fails under xpcshell...
+ let manifests = [
+ { // normal provider
+ name: "provider 1",
+ origin: "https://example1.com",
+ shareURL: "https://example1.com/share/",
+ },
+ { // provider without workerURL
+ name: "provider 2",
+ origin: "https://example2.com",
+ shareURL: "https://example2.com/share/",
+ }
+ ];
+
+ Cu.import("resource:///modules/SocialService.jsm");
+
+ let runner = new AsyncRunner();
+ let next = runner.next.bind(runner);
+ runner.appendIterator(testAddProviders(manifests, next));
+ runner.appendIterator(testGetProvider(manifests, next));
+ runner.appendIterator(testGetProviderList(manifests, next));
+ runner.appendIterator(testAddRemoveProvider(manifests, next));
+ runner.appendIterator(testIsSameOrigin(manifests, next));
+ runner.appendIterator(testResolveUri (manifests, next));
+ runner.appendIterator(testOrderedProviders(manifests, next));
+ runner.appendIterator(testRemoveProviders(manifests, next));
+ runner.next();
+}
+
+function* testAddProviders(manifests, next) {
+ do_check_false(SocialService.enabled);
+ let provider = yield SocialService.addProvider(manifests[0], next);
+ do_check_true(SocialService.enabled);
+ do_check_false(provider.enabled);
+ provider = yield SocialService.addProvider(manifests[1], next);
+ do_check_false(provider.enabled);
+}
+
+function* testRemoveProviders(manifests, next) {
+ do_check_true(SocialService.enabled);
+ yield SocialService.disableProvider(manifests[0].origin, next);
+ yield SocialService.disableProvider(manifests[1].origin, next);
+ do_check_false(SocialService.enabled);
+}
+
+function* testGetProvider(manifests, next) {
+ for (let i = 0; i < manifests.length; i++) {
+ let manifest = manifests[i];
+ let provider = yield SocialService.getProvider(manifest.origin, next);
+ do_check_neq(provider, null);
+ do_check_eq(provider.name, manifest.name);
+ do_check_eq(provider.workerURL, manifest.workerURL);
+ do_check_eq(provider.origin, manifest.origin);
+ }
+ do_check_eq((yield SocialService.getProvider("bogus", next)), null);
+}
+
+function* testGetProviderList(manifests, next) {
+ let providers = yield SocialService.getProviderList(next);
+ do_check_true(providers.length >= manifests.length);
+ for (let i = 0; i < manifests.length; i++) {
+ let providerIdx = providers.map(p => p.origin).indexOf(manifests[i].origin);
+ let provider = providers[providerIdx];
+ do_check_true(!!provider);
+ do_check_false(provider.enabled);
+ do_check_eq(provider.workerURL, manifests[i].workerURL);
+ do_check_eq(provider.name, manifests[i].name);
+ }
+}
+
+function* testAddRemoveProvider(manifests, next) {
+ var threw;
+ try {
+ // Adding a provider whose origin already exists should fail
+ SocialService.addProvider(manifests[0]);
+ } catch (ex) {
+ threw = ex;
+ }
+ do_check_neq(threw.toString().indexOf("SocialService.addProvider: provider with this origin already exists"), -1);
+
+ let originalProviders = yield SocialService.getProviderList(next);
+
+ // Check that provider installation succeeds
+ let newProvider = yield SocialService.addProvider({
+ name: "foo",
+ origin: "http://example3.com"
+ }, next);
+ let retrievedNewProvider = yield SocialService.getProvider(newProvider.origin, next);
+ do_check_eq(newProvider, retrievedNewProvider);
+
+ let providersAfter = yield SocialService.getProviderList(next);
+ do_check_eq(providersAfter.length, originalProviders.length + 1);
+ do_check_neq(providersAfter.indexOf(newProvider), -1);
+
+ // Now remove the provider
+ yield SocialService.disableProvider(newProvider.origin, next);
+ providersAfter = yield SocialService.getProviderList(next);
+ do_check_eq(providersAfter.length, originalProviders.length);
+ do_check_eq(providersAfter.indexOf(newProvider), -1);
+ newProvider = yield SocialService.getProvider(newProvider.origin, next);
+ do_check_true(!newProvider);
+}
+
+function* testIsSameOrigin(manifests, next) {
+ let providers = yield SocialService.getProviderList(next);
+ let provider = providers[0];
+ // provider.origin is a string.
+ do_check_true(provider.isSameOrigin(provider.origin));
+ do_check_true(provider.isSameOrigin(Services.io.newURI(provider.origin, null, null)));
+ do_check_true(provider.isSameOrigin(provider.origin + "/some-sub-page"));
+ do_check_true(provider.isSameOrigin(Services.io.newURI(provider.origin + "/some-sub-page", null, null)));
+ do_check_false(provider.isSameOrigin("http://something.com"));
+ do_check_false(provider.isSameOrigin(Services.io.newURI("http://something.com", null, null)));
+ do_check_false(provider.isSameOrigin("data:text/html,<p>hi"));
+ do_check_true(provider.isSameOrigin("data:text/html,<p>hi", true));
+ do_check_false(provider.isSameOrigin(Services.io.newURI("data:text/html,<p>hi", null, null)));
+ do_check_true(provider.isSameOrigin(Services.io.newURI("data:text/html,<p>hi", null, null), true));
+ // we explicitly handle null and return false
+ do_check_false(provider.isSameOrigin(null));
+}
+
+function* testResolveUri(manifests, next) {
+ let providers = yield SocialService.getProviderList(next);
+ let provider = providers[0];
+ do_check_eq(provider.resolveUri(provider.origin).spec, provider.origin + "/");
+ do_check_eq(provider.resolveUri("foo.html").spec, provider.origin + "/foo.html");
+ do_check_eq(provider.resolveUri("/foo.html").spec, provider.origin + "/foo.html");
+ do_check_eq(provider.resolveUri("http://somewhereelse.com/foo.html").spec, "http://somewhereelse.com/foo.html");
+ do_check_eq(provider.resolveUri("data:text/html,<p>hi").spec, "data:text/html,<p>hi");
+}
+
+function* testOrderedProviders(manifests, next) {
+ let providers = yield SocialService.getProviderList(next);
+
+ // add visits for only one of the providers
+ let visits = [];
+ let startDate = Date.now() * 1000;
+ for (let i = 0; i < 10; i++) {
+ visits.push({
+ uri: Services.io.newURI(providers[1].shareURL + i, null, null),
+ visitDate: startDate + i
+ });
+ }
+
+ PlacesTestUtils.addVisits(visits).then(next);
+ yield;
+ let orderedProviders = yield SocialService.getOrderedProviderList(next);
+ do_check_eq(orderedProviders[0], providers[1]);
+ do_check_eq(orderedProviders[1], providers[0]);
+ do_check_true(orderedProviders[0].frecency > orderedProviders[1].frecency);
+ PlacesTestUtils.clearHistory().then(next);
+ yield;
+}
diff --git a/browser/modules/test/unit/social/test_SocialServiceMigration21.js b/browser/modules/test/unit/social/test_SocialServiceMigration21.js
new file mode 100644
index 000000000..dfe6183bf
--- /dev/null
+++ b/browser/modules/test/unit/social/test_SocialServiceMigration21.js
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+const DEFAULT_PREFS = Services.prefs.getDefaultBranch("social.manifest.");
+
+function run_test() {
+ // Test must run at startup for migration to occur, so we can only test
+ // one migration per test file
+ initApp();
+
+ // NOTE: none of the manifests here can have a workerURL set, or we attempt
+ // to create a FrameWorker and that fails under xpcshell...
+ let manifest = { // normal provider
+ name: "provider 1",
+ origin: "https://example1.com",
+ builtin: true // as of fx22 this should be true for default prefs
+ };
+
+ DEFAULT_PREFS.setCharPref(manifest.origin, JSON.stringify(manifest));
+ Services.prefs.setBoolPref("social.active", true);
+
+ Cu.import("resource:///modules/SocialService.jsm");
+
+ let runner = new AsyncRunner();
+ let next = runner.next.bind(runner);
+ runner.appendIterator(testMigration(manifest, next));
+ runner.next();
+}
+
+function* testMigration(manifest, next) {
+ // look at social.activeProviders, we should have migrated into that, and
+ // we should be set as a user level pref after migration
+ do_check_false(MANIFEST_PREFS.prefHasUserValue(manifest.origin));
+ // we need to access the providers for everything to initialize
+ yield SocialService.getProviderList(next);
+ do_check_true(SocialService.enabled);
+ do_check_true(Services.prefs.prefHasUserValue("social.activeProviders"));
+
+ let activeProviders;
+ let pref = Services.prefs.getComplexValue("social.activeProviders",
+ Ci.nsISupportsString);
+ activeProviders = JSON.parse(pref);
+ do_check_true(activeProviders[manifest.origin]);
+ do_check_true(MANIFEST_PREFS.prefHasUserValue(manifest.origin));
+ do_check_true(JSON.parse(DEFAULT_PREFS.getCharPref(manifest.origin)).builtin);
+
+ let userPref = JSON.parse(MANIFEST_PREFS.getCharPref(manifest.origin));
+ do_check_true(parseInt(userPref.updateDate) > 0);
+ // migrated providers wont have an installDate
+ do_check_true(userPref.installDate === 0);
+}
diff --git a/browser/modules/test/unit/social/test_SocialServiceMigration22.js b/browser/modules/test/unit/social/test_SocialServiceMigration22.js
new file mode 100644
index 000000000..1a3953175
--- /dev/null
+++ b/browser/modules/test/unit/social/test_SocialServiceMigration22.js
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+const DEFAULT_PREFS = Services.prefs.getDefaultBranch("social.manifest.");
+
+function run_test() {
+ // Test must run at startup for migration to occur, so we can only test
+ // one migration per test file
+ initApp();
+
+ // NOTE: none of the manifests here can have a workerURL set, or we attempt
+ // to create a FrameWorker and that fails under xpcshell...
+ let manifest = { // normal provider
+ name: "provider 1",
+ origin: "https://example1.com",
+ builtin: true // as of fx22 this should be true for default prefs
+ };
+
+ DEFAULT_PREFS.setCharPref(manifest.origin, JSON.stringify(manifest));
+
+ // Set both providers active and flag the first one as "current"
+ let activeVal = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ let active = {};
+ active[manifest.origin] = 1;
+ // bad.origin tests that a missing manifest does not break migration, bug 859715
+ active["bad.origin"] = 1;
+ activeVal.data = JSON.stringify(active);
+ Services.prefs.setComplexValue("social.activeProviders",
+ Ci.nsISupportsString, activeVal);
+
+ Cu.import("resource:///modules/SocialService.jsm");
+
+ let runner = new AsyncRunner();
+ let next = runner.next.bind(runner);
+ runner.appendIterator(testMigration(manifest, next));
+ runner.next();
+}
+
+function* testMigration(manifest, next) {
+ // look at social.activeProviders, we should have migrated into that, and
+ // we should be set as a user level pref after migration
+ do_check_false(MANIFEST_PREFS.prefHasUserValue(manifest.origin));
+ // we need to access the providers for everything to initialize
+ yield SocialService.getProviderList(next);
+ do_check_true(SocialService.enabled);
+ do_check_true(Services.prefs.prefHasUserValue("social.activeProviders"));
+
+ let activeProviders;
+ let pref = Services.prefs.getComplexValue("social.activeProviders",
+ Ci.nsISupportsString);
+ activeProviders = JSON.parse(pref);
+ do_check_true(activeProviders[manifest.origin]);
+ do_check_true(MANIFEST_PREFS.prefHasUserValue(manifest.origin));
+ do_check_true(JSON.parse(DEFAULT_PREFS.getCharPref(manifest.origin)).builtin);
+
+ let userPref = JSON.parse(MANIFEST_PREFS.getCharPref(manifest.origin));
+ do_check_true(parseInt(userPref.updateDate) > 0);
+ // migrated providers wont have an installDate
+ do_check_true(userPref.installDate === 0);
+
+ // bug 859715, this should have been removed during migration
+ do_check_false(!!activeProviders["bad.origin"]);
+}
diff --git a/browser/modules/test/unit/social/test_SocialServiceMigration29.js b/browser/modules/test/unit/social/test_SocialServiceMigration29.js
new file mode 100644
index 000000000..824673ddf
--- /dev/null
+++ b/browser/modules/test/unit/social/test_SocialServiceMigration29.js
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+
+function run_test() {
+ // Test must run at startup for migration to occur, so we can only test
+ // one migration per test file
+ initApp();
+
+ // NOTE: none of the manifests here can have a workerURL set, or we attempt
+ // to create a FrameWorker and that fails under xpcshell...
+ let manifest = { // normal provider
+ name: "provider 1",
+ origin: "https://example1.com",
+ };
+
+ MANIFEST_PREFS.setCharPref(manifest.origin, JSON.stringify(manifest));
+
+ // Set both providers active and flag the first one as "current"
+ let activeVal = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ let active = {};
+ active[manifest.origin] = 1;
+ activeVal.data = JSON.stringify(active);
+ Services.prefs.setComplexValue("social.activeProviders",
+ Ci.nsISupportsString, activeVal);
+
+ // social.enabled pref is the key focus of this test. We set the user pref,
+ // and then migration should a) remove the provider from activeProviders and
+ // b) unset social.enabled
+ Services.prefs.setBoolPref("social.enabled", false);
+
+ Cu.import("resource:///modules/SocialService.jsm");
+
+ let runner = new AsyncRunner();
+ let next = runner.next.bind(runner);
+ runner.appendIterator(testMigration(manifest, next));
+ runner.next();
+}
+
+function* testMigration(manifest, next) {
+ // look at social.activeProviders, we should have migrated into that, and
+ // we should be set as a user level pref after migration
+ do_check_true(Services.prefs.prefHasUserValue("social.enabled"));
+ do_check_true(MANIFEST_PREFS.prefHasUserValue(manifest.origin));
+ // we need to access the providers for everything to initialize
+ yield SocialService.getProviderList(next);
+ do_check_false(SocialService.enabled);
+ do_check_false(Services.prefs.prefHasUserValue("social.enabled"));
+ do_check_true(Services.prefs.prefHasUserValue("social.activeProviders"));
+
+ let activeProviders;
+ let pref = Services.prefs.getComplexValue("social.activeProviders",
+ Ci.nsISupportsString).data;
+ activeProviders = JSON.parse(pref);
+ do_check_true(activeProviders[manifest.origin] == undefined);
+ do_check_true(MANIFEST_PREFS.prefHasUserValue(manifest.origin));
+}
diff --git a/browser/modules/test/unit/social/test_social.js b/browser/modules/test/unit/social/test_social.js
new file mode 100644
index 000000000..3117306c1
--- /dev/null
+++ b/browser/modules/test/unit/social/test_social.js
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ // we are testing worker startup specifically
+ do_test_pending();
+ add_test(testStartupEnabled);
+ add_test(testDisableAfterStartup);
+ do_initialize_social(true, run_next_test);
+}
+
+function testStartupEnabled() {
+ // wait on startup before continuing
+ do_check_eq(Social.providers.length, 2, "two social providers enabled");
+ do_check_true(Social.providers[0].enabled, "provider 0 is enabled");
+ do_check_true(Social.providers[1].enabled, "provider 1 is enabled");
+ run_next_test();
+}
+
+function testDisableAfterStartup() {
+ let SocialService = Cu.import("resource:///modules/SocialService.jsm", {}).SocialService;
+ SocialService.disableProvider(Social.providers[0].origin, function() {
+ do_wait_observer("social:providers-changed", function() {
+ do_check_eq(Social.enabled, false, "Social is disabled");
+ do_check_eq(Social.providers.length, 0, "no social providers available");
+ do_test_finished();
+ run_next_test();
+ });
+ SocialService.disableProvider(Social.providers[0].origin)
+ });
+}
diff --git a/browser/modules/test/unit/social/test_socialDisabledStartup.js b/browser/modules/test/unit/social/test_socialDisabledStartup.js
new file mode 100644
index 000000000..a2f7a1d5a
--- /dev/null
+++ b/browser/modules/test/unit/social/test_socialDisabledStartup.js
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ // we are testing worker startup specifically
+ do_test_pending();
+ add_test(testStartupDisabled);
+ add_test(testEnableAfterStartup);
+ do_initialize_social(false, run_next_test);
+}
+
+function testStartupDisabled() {
+ // wait on startup before continuing
+ do_check_false(Social.enabled, "Social is disabled");
+ do_check_eq(Social.providers.length, 0, "zero social providers available");
+ run_next_test();
+}
+
+function testEnableAfterStartup() {
+ do_add_providers(function () {
+ do_check_true(Social.enabled, "Social is enabled");
+ do_check_eq(Social.providers.length, 2, "two social providers available");
+ do_check_true(Social.providers[0].enabled, "provider 0 is enabled");
+ do_check_true(Social.providers[1].enabled, "provider 1 is enabled");
+ do_test_finished();
+ run_next_test();
+ });
+}
diff --git a/browser/modules/test/unit/social/xpcshell.ini b/browser/modules/test/unit/social/xpcshell.ini
new file mode 100644
index 000000000..277dd4f49
--- /dev/null
+++ b/browser/modules/test/unit/social/xpcshell.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+head = head.js
+tail =
+firefox-appdir = browser
+skip-if = toolkit == 'android'
+support-files = blocklist.xml
+
+[test_social.js]
+[test_socialDisabledStartup.js]
+[test_SocialService.js]
+[test_SocialServiceMigration21.js]
+[test_SocialServiceMigration22.js]
+[test_SocialServiceMigration29.js]
diff --git a/browser/modules/test/usageTelemetrySearchSuggestions.sjs b/browser/modules/test/usageTelemetrySearchSuggestions.sjs
new file mode 100644
index 000000000..1978b4f66
--- /dev/null
+++ b/browser/modules/test/usageTelemetrySearchSuggestions.sjs
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(req, resp) {
+ let suffixes = ["foo", "bar"];
+ let data = [req.queryString, suffixes.map(s => req.queryString + s)];
+ resp.setHeader("Content-Type", "application/json", false);
+ resp.write(JSON.stringify(data));
+}
diff --git a/browser/modules/test/usageTelemetrySearchSuggestions.xml b/browser/modules/test/usageTelemetrySearchSuggestions.xml
new file mode 100644
index 000000000..76276045d
--- /dev/null
+++ b/browser/modules/test/usageTelemetrySearchSuggestions.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_UsageTelemetry usageTelemetrySearchSuggestions.xml</ShortName>
+<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/modules/test/usageTelemetrySearchSuggestions.sjs?{searchTerms}"/>
+<Url type="text/html" method="GET" template="http://example.com" rel="searchform"/>
+</SearchPlugin>
diff --git a/browser/modules/test/xpcshell/.eslintrc.js b/browser/modules/test/xpcshell/.eslintrc.js
new file mode 100644
index 000000000..fee088c17
--- /dev/null
+++ b/browser/modules/test/xpcshell/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/browser/modules/test/xpcshell/test_AttributionCode.js b/browser/modules/test/xpcshell/test_AttributionCode.js
new file mode 100644
index 000000000..d979ae845
--- /dev/null
+++ b/browser/modules/test/xpcshell/test_AttributionCode.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const {interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource:///modules/AttributionCode.jsm");
+Cu.import('resource://gre/modules/osfile.jsm');
+Cu.import("resource://gre/modules/Services.jsm");
+
+let validAttrCodes = [
+ {code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set)",
+ parsed: {"source": "google.com", "medium": "organic",
+ "campaign": "(not%20set)", "content": "(not%20set)"}},
+ {code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D%26content%3D",
+ parsed: {"source": "google.com", "medium": "organic"}},
+ {code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)",
+ parsed: {"source": "google.com", "medium": "organic", "campaign": "(not%20set)"}},
+ {code: "source%3Dgoogle.com%26medium%3Dorganic",
+ parsed: {"source": "google.com", "medium": "organic"}},
+ {code: "source%3Dgoogle.com",
+ parsed: {"source": "google.com"}},
+ {code: "medium%3Dgoogle.com",
+ parsed: {"medium": "google.com"}},
+ {code: "campaign%3Dgoogle.com",
+ parsed: {"campaign": "google.com"}},
+ {code: "content%3Dgoogle.com",
+ parsed: {"content": "google.com"}}
+];
+
+let invalidAttrCodes = [
+ // Empty string
+ "",
+ // Not escaped
+ "source=google.com&medium=organic&campaign=(not set)&content=(not set)",
+ // Too long
+ "source%3Dreallyreallyreallyreallyreallyreallyreallyreallyreallylongdomain.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3Dalmostexactlyenoughcontenttomakethisstringlongerthanthe200characterlimit",
+ // Unknown key name
+ "source%3Dgoogle.com%26medium%3Dorganic%26large%3Dgeneticallymodified",
+ // Empty key name
+ "source%3Dgoogle.com%26medium%3Dorganic%26%3Dgeneticallymodified"
+];
+
+function* writeAttributionFile(data) {
+ let appDir = Services.dirsvc.get("LocalAppData", Ci.nsIFile);
+ let file = appDir.clone();
+ file.append(Services.appinfo.vendor || "mozilla");
+ file.append(AppConstants.MOZ_APP_NAME);
+
+ yield OS.File.makeDir(file.path,
+ {from: appDir.path, ignoreExisting: true});
+
+ file.append("postSigningData");
+ yield OS.File.writeAtomic(file.path, data);
+}
+
+/**
+ * Test validation of attribution codes,
+ * to make sure we reject bad ones and accept good ones.
+ */
+add_task(function* testValidAttrCodes() {
+ for (let entry of validAttrCodes) {
+ AttributionCode._clearCache();
+ yield writeAttributionFile(entry.code);
+ let result = yield AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code);
+ }
+ AttributionCode._clearCache();
+});
+
+/**
+ * Make sure codes with various formatting errors are not seen as valid.
+ */
+add_task(function* testInvalidAttrCodes() {
+ for (let code of invalidAttrCodes) {
+ AttributionCode._clearCache();
+ yield writeAttributionFile(code);
+ let result = yield AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {},
+ "Code should have failed to parse: " + code);
+ }
+ AttributionCode._clearCache();
+});
+
+/**
+ * Test the cache by deleting the attribution data file
+ * and making sure we still get the expected code.
+ */
+add_task(function* testDeletedFile() {
+ // Set up the test by clearing the cache and writing a valid file.
+ yield writeAttributionFile(validAttrCodes[0].code);
+ let result = yield AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, validAttrCodes[0].parsed,
+ "The code should be readable directly from the file");
+
+ // Delete the file and make sure we can still read the value back from cache.
+ yield AttributionCode.deleteFileAsync();
+ result = yield AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, validAttrCodes[0].parsed,
+ "The code should be readable from the cache");
+
+ // Clear the cache and check we can't read anything.
+ AttributionCode._clearCache();
+ result = yield AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {},
+ "Shouldn't be able to get a code after file is deleted and cache is cleared");
+});
diff --git a/browser/modules/test/xpcshell/test_DirectoryLinksProvider.js b/browser/modules/test/xpcshell/test_DirectoryLinksProvider.js
new file mode 100644
index 000000000..712f52fa6
--- /dev/null
+++ b/browser/modules/test/xpcshell/test_DirectoryLinksProvider.js
@@ -0,0 +1,1854 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+/**
+ * This file tests the DirectoryLinksProvider singleton in the DirectoryLinksProvider.jsm module.
+ */
+
+var { classes: Cc, interfaces: Ci, results: Cr, utils: Cu, Constructor: CC } = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource:///modules/DirectoryLinksProvider.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Http.jsm");
+Cu.import("resource://testing-common/httpd.js");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
+ "resource://gre/modules/NewTabUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils",
+ "resource://testing-common/PlacesTestUtils.jsm");
+
+do_get_profile();
+
+const DIRECTORY_LINKS_FILE = "directoryLinks.json";
+const DIRECTORY_FRECENCY = 1000;
+const SUGGESTED_FRECENCY = Infinity;
+const kURLData = {"directory": [{"url":"http://example.com", "title":"LocalSource"}]};
+const kTestURL = 'data:application/json,' + JSON.stringify(kURLData);
+
+// DirectoryLinksProvider preferences
+const kLocalePref = DirectoryLinksProvider._observedPrefs.prefSelectedLocale;
+const kSourceUrlPref = DirectoryLinksProvider._observedPrefs.linksURL;
+const kPingUrlPref = "browser.newtabpage.directory.ping";
+const kNewtabEnhancedPref = "browser.newtabpage.enhanced";
+
+// httpd settings
+var server;
+const kDefaultServerPort = 9000;
+const kBaseUrl = "http://localhost:" + kDefaultServerPort;
+const kExamplePath = "/exampleTest/";
+const kFailPath = "/fail/";
+const kPingPath = "/ping/";
+const kExampleURL = kBaseUrl + kExamplePath;
+const kFailURL = kBaseUrl + kFailPath;
+const kPingUrl = kBaseUrl + kPingPath;
+
+// app/profile/firefox.js are not avaialble in xpcshell: hence, preset them
+Services.prefs.setCharPref(kLocalePref, "en-US");
+Services.prefs.setCharPref(kSourceUrlPref, kTestURL);
+Services.prefs.setCharPref(kPingUrlPref, kPingUrl);
+Services.prefs.setBoolPref(kNewtabEnhancedPref, true);
+
+const kHttpHandlerData = {};
+kHttpHandlerData[kExamplePath] = {"directory": [{"url":"http://example.com", "title":"RemoteSource"}]};
+
+const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream");
+
+var gLastRequestPath;
+
+var suggestedTile1 = {
+ url: "http://turbotax.com",
+ type: "affiliate",
+ lastVisitDate: 4,
+ adgroup_name: "Adgroup1",
+ frecent_sites: [
+ "taxact.com",
+ "hrblock.com",
+ "1040.com",
+ "taxslayer.com"
+ ]
+};
+var suggestedTile2 = {
+ url: "http://irs.gov",
+ type: "affiliate",
+ lastVisitDate: 3,
+ adgroup_name: "Adgroup2",
+ frecent_sites: [
+ "taxact.com",
+ "hrblock.com",
+ "freetaxusa.com",
+ "taxslayer.com"
+ ]
+};
+var suggestedTile3 = {
+ url: "http://hrblock.com",
+ type: "affiliate",
+ lastVisitDate: 2,
+ adgroup_name: "Adgroup3",
+ frecent_sites: [
+ "taxact.com",
+ "freetaxusa.com",
+ "1040.com",
+ "taxslayer.com"
+ ]
+};
+var suggestedTile4 = {
+ url: "http://sponsoredtile.com",
+ type: "sponsored",
+ lastVisitDate: 1,
+ adgroup_name: "Adgroup4",
+ frecent_sites: [
+ "sponsoredtarget.com"
+ ]
+}
+var suggestedTile5 = {
+ url: "http://eviltile.com",
+ type: "affiliate",
+ lastVisitDate: 5,
+ explanation: "This is an evil tile <form><button formaction='javascript:alert(1)''>X</button></form> muhahaha",
+ adgroup_name: "WE ARE EVIL <link rel='import' href='test.svg'/>",
+ frecent_sites: [
+ "eviltarget.com"
+ ]
+}
+var someOtherSite = {url: "http://someothersite.com", title: "Not_A_Suggested_Site"};
+
+function getHttpHandler(path) {
+ let code = 200;
+ let body = JSON.stringify(kHttpHandlerData[path]);
+ if (path == kFailPath) {
+ code = 204;
+ }
+ return function(aRequest, aResponse) {
+ gLastRequestPath = aRequest.path;
+ aResponse.setStatusLine(null, code);
+ aResponse.setHeader("Content-Type", "application/json");
+ aResponse.write(body);
+ };
+}
+
+function isIdentical(actual, expected) {
+ if (expected == null) {
+ do_check_eq(actual, expected);
+ }
+ else if (typeof expected == "object") {
+ // Make sure all the keys match up
+ do_check_eq(Object.keys(actual).sort() + "", Object.keys(expected).sort());
+
+ // Recursively check each value individually
+ Object.keys(expected).forEach(key => {
+ isIdentical(actual[key], expected[key]);
+ });
+ }
+ else {
+ do_check_eq(actual, expected);
+ }
+}
+
+function fetchData() {
+ let deferred = Promise.defer();
+
+ DirectoryLinksProvider.getLinks(linkData => {
+ deferred.resolve(linkData);
+ });
+ return deferred.promise;
+}
+
+function readJsonFile(jsonFile = DIRECTORY_LINKS_FILE) {
+ let decoder = new TextDecoder();
+ let directoryLinksFilePath = OS.Path.join(OS.Constants.Path.localProfileDir, jsonFile);
+ return OS.File.read(directoryLinksFilePath).then(array => {
+ let json = decoder.decode(array);
+ return JSON.parse(json);
+ }, () => { return "" });
+}
+
+function cleanJsonFile(jsonFile = DIRECTORY_LINKS_FILE) {
+ let directoryLinksFilePath = OS.Path.join(OS.Constants.Path.localProfileDir, jsonFile);
+ return OS.File.remove(directoryLinksFilePath);
+}
+
+function LinksChangeObserver() {
+ this.deferred = Promise.defer();
+ this.onManyLinksChanged = () => this.deferred.resolve();
+ this.onDownloadFail = this.onManyLinksChanged;
+}
+
+function promiseDirectoryDownloadOnPrefChange(pref, newValue) {
+ let oldValue = Services.prefs.getCharPref(pref);
+ if (oldValue != newValue) {
+ // if the preference value is already equal to newValue
+ // the pref service will not call our observer and we
+ // deadlock. Hence only setup observer if values differ
+ let observer = new LinksChangeObserver();
+ DirectoryLinksProvider.addObserver(observer);
+ Services.prefs.setCharPref(pref, newValue);
+ return observer.deferred.promise.then(() => {
+ DirectoryLinksProvider.removeObserver(observer);
+ });
+ }
+ return Promise.resolve();
+}
+
+function promiseSetupDirectoryLinksProvider(options = {}) {
+ return Task.spawn(function*() {
+ let linksURL = options.linksURL || kTestURL;
+ yield DirectoryLinksProvider.init();
+ yield promiseDirectoryDownloadOnPrefChange(kLocalePref, options.locale || "en-US");
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, linksURL);
+ do_check_eq(DirectoryLinksProvider._linksURL, linksURL);
+ DirectoryLinksProvider._lastDownloadMS = options.lastDownloadMS || 0;
+ });
+}
+
+function promiseCleanDirectoryLinksProvider() {
+ return Task.spawn(function*() {
+ yield promiseDirectoryDownloadOnPrefChange(kLocalePref, "en-US");
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kTestURL);
+ yield DirectoryLinksProvider._clearFrequencyCap();
+ yield DirectoryLinksProvider._loadInadjacentSites();
+ DirectoryLinksProvider._lastDownloadMS = 0;
+ DirectoryLinksProvider.reset();
+ });
+}
+
+function run_test() {
+ // Set up a mock HTTP server to serve a directory page
+ server = new HttpServer();
+ server.registerPrefixHandler(kExamplePath, getHttpHandler(kExamplePath));
+ server.registerPrefixHandler(kFailPath, getHttpHandler(kFailPath));
+ server.start(kDefaultServerPort);
+ NewTabUtils.init();
+
+ run_next_test();
+
+ // Teardown.
+ do_register_cleanup(function() {
+ server.stop(function() { });
+ DirectoryLinksProvider.reset();
+ Services.prefs.clearUserPref(kLocalePref);
+ Services.prefs.clearUserPref(kSourceUrlPref);
+ Services.prefs.clearUserPref(kPingUrlPref);
+ Services.prefs.clearUserPref(kNewtabEnhancedPref);
+ });
+}
+
+
+function setTimeout(fun, timeout) {
+ let timer = Components.classes["@mozilla.org/timer;1"]
+ .createInstance(Components.interfaces.nsITimer);
+ var event = {
+ notify: function () {
+ fun();
+ }
+ };
+ timer.initWithCallback(event, timeout,
+ Components.interfaces.nsITimer.TYPE_ONE_SHOT);
+ return timer;
+}
+
+add_task(function test_shouldUpdateSuggestedTile() {
+ let suggestedLink = {
+ targetedSite: "somesite.com"
+ };
+
+ // DirectoryLinksProvider has no suggested tile and no top sites => no need to update
+ do_check_eq(DirectoryLinksProvider._getCurrentTopSiteCount(), 0);
+ isIdentical(NewTabUtils.getProviderLinks(), []);
+ do_check_eq(DirectoryLinksProvider._shouldUpdateSuggestedTile(), false);
+
+ // DirectoryLinksProvider has a suggested tile and no top sites => need to update
+ let origGetProviderLinks = NewTabUtils.getProviderLinks;
+ NewTabUtils.getProviderLinks = (provider) => [suggestedLink];
+
+ do_check_eq(DirectoryLinksProvider._getCurrentTopSiteCount(), 0);
+ isIdentical(NewTabUtils.getProviderLinks(), [suggestedLink]);
+ do_check_eq(DirectoryLinksProvider._shouldUpdateSuggestedTile(), true);
+
+ // DirectoryLinksProvider has a suggested tile and 8 top sites => no need to update
+ let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+ DirectoryLinksProvider._getCurrentTopSiteCount = () => 8;
+
+ do_check_eq(DirectoryLinksProvider._getCurrentTopSiteCount(), 8);
+ isIdentical(NewTabUtils.getProviderLinks(), [suggestedLink]);
+ do_check_eq(DirectoryLinksProvider._shouldUpdateSuggestedTile(), false);
+
+ // DirectoryLinksProvider has no suggested tile and 8 top sites => need to update
+ NewTabUtils.getProviderLinks = origGetProviderLinks;
+ do_check_eq(DirectoryLinksProvider._getCurrentTopSiteCount(), 8);
+ isIdentical(NewTabUtils.getProviderLinks(), []);
+ do_check_eq(DirectoryLinksProvider._shouldUpdateSuggestedTile(), true);
+
+ // Cleanup
+ DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+});
+
+add_task(function* test_updateSuggestedTile() {
+ let topSites = ["site0.com", "1040.com", "site2.com", "hrblock.com", "site4.com", "freetaxusa.com", "site6.com"];
+
+ // Initial setup
+ let data = {"suggested": [suggestedTile1, suggestedTile2, suggestedTile3], "directory": [someOtherSite]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+
+ let testObserver = new TestFirstRun();
+ DirectoryLinksProvider.addObserver(testObserver);
+
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+ let links = yield fetchData();
+
+ let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+ NewTabUtils.isTopPlacesSite = function(site) {
+ return topSites.indexOf(site) >= 0;
+ }
+
+ let origGetProviderLinks = NewTabUtils.getProviderLinks;
+ NewTabUtils.getProviderLinks = function(provider) {
+ return links;
+ }
+
+ let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+ DirectoryLinksProvider._getCurrentTopSiteCount = () => 8;
+
+ do_check_eq(DirectoryLinksProvider._updateSuggestedTile(), undefined);
+
+ function TestFirstRun() {
+ this.promise = new Promise(resolve => {
+ this.onLinkChanged = (directoryLinksProvider, link) => {
+ links.unshift(link);
+ let possibleLinks = [suggestedTile1.url, suggestedTile2.url, suggestedTile3.url];
+
+ isIdentical([...DirectoryLinksProvider._topSitesWithSuggestedLinks], ["hrblock.com", "1040.com", "freetaxusa.com"]);
+ do_check_true(possibleLinks.indexOf(link.url) > -1);
+ do_check_eq(link.frecency, SUGGESTED_FRECENCY);
+ do_check_eq(link.type, "affiliate");
+ resolve();
+ };
+ });
+ }
+
+ function TestChangingSuggestedTile() {
+ this.count = 0;
+ this.promise = new Promise(resolve => {
+ this.onLinkChanged = (directoryLinksProvider, link) => {
+ this.count++;
+ let possibleLinks = [suggestedTile1.url, suggestedTile2.url, suggestedTile3.url];
+
+ do_check_true(possibleLinks.indexOf(link.url) > -1);
+ do_check_eq(link.type, "affiliate");
+ do_check_true(this.count <= 2);
+
+ if (this.count == 1) {
+ // The removed suggested link is the one we added initially.
+ do_check_eq(link.url, links.shift().url);
+ do_check_eq(link.frecency, SUGGESTED_FRECENCY);
+ } else {
+ links.unshift(link);
+ do_check_eq(link.frecency, SUGGESTED_FRECENCY);
+ }
+ isIdentical([...DirectoryLinksProvider._topSitesWithSuggestedLinks], ["hrblock.com", "freetaxusa.com"]);
+ resolve();
+ }
+ });
+ }
+
+ function TestRemovingSuggestedTile() {
+ this.count = 0;
+ this.promise = new Promise(resolve => {
+ this.onLinkChanged = (directoryLinksProvider, link) => {
+ this.count++;
+
+ do_check_eq(link.type, "affiliate");
+ do_check_eq(this.count, 1);
+ do_check_eq(link.frecency, SUGGESTED_FRECENCY);
+ do_check_eq(link.url, links.shift().url);
+ isIdentical([...DirectoryLinksProvider._topSitesWithSuggestedLinks], []);
+ resolve();
+ }
+ });
+ }
+
+ // Test first call to '_updateSuggestedTile()', called when fetching directory links.
+ yield testObserver.promise;
+ DirectoryLinksProvider.removeObserver(testObserver);
+
+ // Removing a top site that doesn't have a suggested link should
+ // not change the current suggested tile.
+ let removedTopsite = topSites.shift();
+ do_check_eq(removedTopsite, "site0.com");
+ do_check_false(NewTabUtils.isTopPlacesSite(removedTopsite));
+ let updateSuggestedTile = DirectoryLinksProvider._handleLinkChanged({
+ url: "http://" + removedTopsite,
+ type: "history",
+ });
+ do_check_false(updateSuggestedTile);
+
+ // Removing a top site that has a suggested link should
+ // remove any current suggested tile and add a new one.
+ testObserver = new TestChangingSuggestedTile();
+ DirectoryLinksProvider.addObserver(testObserver);
+ removedTopsite = topSites.shift();
+ do_check_eq(removedTopsite, "1040.com");
+ do_check_false(NewTabUtils.isTopPlacesSite(removedTopsite));
+ DirectoryLinksProvider.onLinkChanged(DirectoryLinksProvider, {
+ url: "http://" + removedTopsite,
+ type: "history",
+ });
+ yield testObserver.promise;
+ do_check_eq(testObserver.count, 2);
+ DirectoryLinksProvider.removeObserver(testObserver);
+
+ // Removing all top sites with suggested links should remove
+ // the current suggested link and not replace it.
+ topSites = [];
+ testObserver = new TestRemovingSuggestedTile();
+ DirectoryLinksProvider.addObserver(testObserver);
+ DirectoryLinksProvider.onManyLinksChanged();
+ yield testObserver.promise;
+
+ // Cleanup
+ yield promiseCleanDirectoryLinksProvider();
+ NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+ NewTabUtils.getProviderLinks = origGetProviderLinks;
+ DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+});
+
+add_task(function* test_suggestedLinksMap() {
+ let data = {"suggested": [suggestedTile1, suggestedTile2, suggestedTile3, suggestedTile4], "directory": [someOtherSite]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+ let links = yield fetchData();
+
+ // Ensure the suggested tiles were not considered directory tiles.
+ do_check_eq(links.length, 1);
+ let expected_data = [{url: "http://someothersite.com", title: "Not_A_Suggested_Site", frecency: DIRECTORY_FRECENCY, lastVisitDate: 1}];
+ isIdentical(links, expected_data);
+
+ // Check for correctly saved suggested tiles data.
+ expected_data = {
+ "taxact.com": [suggestedTile1, suggestedTile2, suggestedTile3],
+ "hrblock.com": [suggestedTile1, suggestedTile2],
+ "1040.com": [suggestedTile1, suggestedTile3],
+ "taxslayer.com": [suggestedTile1, suggestedTile2, suggestedTile3],
+ "freetaxusa.com": [suggestedTile2, suggestedTile3],
+ "sponsoredtarget.com": [suggestedTile4],
+ };
+
+ let suggestedSites = [...DirectoryLinksProvider._suggestedLinks.keys()];
+ do_check_eq(suggestedSites.indexOf("sponsoredtarget.com"), 5);
+ do_check_eq(suggestedSites.length, Object.keys(expected_data).length);
+
+ DirectoryLinksProvider._suggestedLinks.forEach((suggestedLinks, site) => {
+ let suggestedLinksItr = suggestedLinks.values();
+ for (let link of expected_data[site]) {
+ let linkCopy = JSON.parse(JSON.stringify(link));
+ linkCopy.targetedName = link.adgroup_name;
+ linkCopy.explanation = "";
+ isIdentical(suggestedLinksItr.next().value, linkCopy);
+ }
+ })
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_topSitesWithSuggestedLinks() {
+ let topSites = ["site0.com", "1040.com", "site2.com", "hrblock.com", "site4.com", "freetaxusa.com", "site6.com"];
+ let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+ NewTabUtils.isTopPlacesSite = function(site) {
+ return topSites.indexOf(site) >= 0;
+ }
+
+ // Mock out getProviderLinks() so we don't have to populate cache in NewTabUtils
+ let origGetProviderLinks = NewTabUtils.getProviderLinks;
+ NewTabUtils.getProviderLinks = function(provider) {
+ return [];
+ }
+
+ // We start off with no top sites with suggested links.
+ do_check_eq(DirectoryLinksProvider._topSitesWithSuggestedLinks.size, 0);
+
+ let data = {"suggested": [suggestedTile1, suggestedTile2, suggestedTile3], "directory": [someOtherSite]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+ yield fetchData();
+
+ // Check we've populated suggested links as expected.
+ do_check_eq(DirectoryLinksProvider._suggestedLinks.size, 5);
+
+ // When many sites change, we update _topSitesWithSuggestedLinks as expected.
+ let expectedTopSitesWithSuggestedLinks = ["hrblock.com", "1040.com", "freetaxusa.com"];
+ DirectoryLinksProvider._handleManyLinksChanged();
+ isIdentical([...DirectoryLinksProvider._topSitesWithSuggestedLinks], expectedTopSitesWithSuggestedLinks);
+
+ // Removing site6.com as a topsite has no impact on _topSitesWithSuggestedLinks.
+ let popped = topSites.pop();
+ DirectoryLinksProvider._handleLinkChanged({url: "http://" + popped});
+ isIdentical([...DirectoryLinksProvider._topSitesWithSuggestedLinks], expectedTopSitesWithSuggestedLinks);
+
+ // Removing freetaxusa.com as a topsite will remove it from _topSitesWithSuggestedLinks.
+ popped = topSites.pop();
+ expectedTopSitesWithSuggestedLinks.pop();
+ DirectoryLinksProvider._handleLinkChanged({url: "http://" + popped});
+ isIdentical([...DirectoryLinksProvider._topSitesWithSuggestedLinks], expectedTopSitesWithSuggestedLinks);
+
+ // Re-adding freetaxusa.com as a topsite will add it to _topSitesWithSuggestedLinks.
+ topSites.push(popped);
+ expectedTopSitesWithSuggestedLinks.push(popped);
+ DirectoryLinksProvider._handleLinkChanged({url: "http://" + popped});
+ isIdentical([...DirectoryLinksProvider._topSitesWithSuggestedLinks], expectedTopSitesWithSuggestedLinks);
+
+ // Cleanup.
+ NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+ NewTabUtils.getProviderLinks = origGetProviderLinks;
+});
+
+add_task(function* test_suggestedAttributes() {
+ let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+ NewTabUtils.isTopPlacesSite = () => true;
+
+ let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+ DirectoryLinksProvider._getCurrentTopSiteCount = () => 8;
+
+ let frecent_sites = "addons.mozilla.org,air.mozilla.org,blog.mozilla.org,bugzilla.mozilla.org,developer.mozilla.org,etherpad.mozilla.org,forums.mozillazine.org,hacks.mozilla.org,hg.mozilla.org,mozilla.org,planet.mozilla.org,quality.mozilla.org,support.mozilla.org,treeherder.mozilla.org,wiki.mozilla.org".split(",");
+ let imageURI = "https://image/";
+ let title = "the title";
+ let type = "affiliate";
+ let url = "http://test.url/";
+ let adgroup_name = "Mozilla";
+ let data = {
+ suggested: [{
+ frecent_sites,
+ imageURI,
+ title,
+ type,
+ url,
+ adgroup_name
+ }]
+ };
+ let dataURI = "data:application/json," + escape(JSON.stringify(data));
+
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ // Wait for links to get loaded
+ let gLinks = NewTabUtils.links;
+ gLinks.addProvider(DirectoryLinksProvider);
+ gLinks.populateCache();
+ yield new Promise(resolve => {
+ NewTabUtils.allPages.register({
+ observe: _ => _,
+ update() {
+ NewTabUtils.allPages.unregister(this);
+ resolve();
+ }
+ });
+ });
+
+ // Make sure we get the expected attributes on the suggested tile
+ let link = gLinks.getLinks()[0];
+ do_check_eq(link.imageURI, imageURI);
+ do_check_eq(link.targetedName, "Mozilla");
+ do_check_eq(link.targetedSite, frecent_sites[0]);
+ do_check_eq(link.title, title);
+ do_check_eq(link.type, type);
+ do_check_eq(link.url, url);
+
+ // Cleanup.
+ NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+ DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+ gLinks.removeProvider(DirectoryLinksProvider);
+ DirectoryLinksProvider.removeObserver(gLinks);
+});
+
+add_task(function* test_frequencyCappedSites_views() {
+ Services.prefs.setCharPref(kPingUrlPref, "");
+ let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+ NewTabUtils.isTopPlacesSite = () => true;
+
+ let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+ DirectoryLinksProvider._getCurrentTopSiteCount = () => 8;
+
+ let testUrl = "http://frequency.capped/link";
+ let targets = ["top.site.com"];
+ let data = {
+ suggested: [{
+ type: "affiliate",
+ frecent_sites: targets,
+ url: testUrl,
+ frequency_caps: {daily: 5},
+ adgroup_name: "Test"
+ }],
+ directory: [{
+ type: "organic",
+ url: "http://directory.site/"
+ }]
+ };
+ let dataURI = "data:application/json," + JSON.stringify(data);
+
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ // Wait for links to get loaded
+ let gLinks = NewTabUtils.links;
+ gLinks.addProvider(DirectoryLinksProvider);
+ gLinks.populateCache();
+ yield new Promise(resolve => {
+ NewTabUtils.allPages.register({
+ observe: _ => _,
+ update() {
+ NewTabUtils.allPages.unregister(this);
+ resolve();
+ }
+ });
+ });
+
+ function synthesizeAction(action) {
+ DirectoryLinksProvider.reportSitesAction([{
+ link: {
+ targetedSite: targets[0],
+ url: testUrl
+ }
+ }], action, 0);
+ }
+
+ function checkFirstTypeAndLength(type, length) {
+ let links = gLinks.getLinks();
+ do_check_eq(links[0].type, type);
+ do_check_eq(links.length, length);
+ }
+
+ // Make sure we get 5 views of the link before it is removed
+ checkFirstTypeAndLength("affiliate", 2);
+ synthesizeAction("view");
+ checkFirstTypeAndLength("affiliate", 2);
+ synthesizeAction("view");
+ checkFirstTypeAndLength("affiliate", 2);
+ synthesizeAction("view");
+ checkFirstTypeAndLength("affiliate", 2);
+ synthesizeAction("view");
+ checkFirstTypeAndLength("affiliate", 2);
+ synthesizeAction("view");
+ checkFirstTypeAndLength("organic", 1);
+
+ // Cleanup.
+ NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+ DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+ gLinks.removeProvider(DirectoryLinksProvider);
+ DirectoryLinksProvider.removeObserver(gLinks);
+ Services.prefs.setCharPref(kPingUrlPref, kPingUrl);
+});
+
+add_task(function* test_frequencyCappedSites_click() {
+ Services.prefs.setCharPref(kPingUrlPref, "");
+ let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+ NewTabUtils.isTopPlacesSite = () => true;
+
+ let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+ DirectoryLinksProvider._getCurrentTopSiteCount = () => 8;
+
+ let testUrl = "http://frequency.capped/link";
+ let targets = ["top.site.com"];
+ let data = {
+ suggested: [{
+ type: "affiliate",
+ frecent_sites: targets,
+ url: testUrl,
+ adgroup_name: "Test"
+ }],
+ directory: [{
+ type: "organic",
+ url: "http://directory.site/"
+ }]
+ };
+ let dataURI = "data:application/json," + JSON.stringify(data);
+
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ // Wait for links to get loaded
+ let gLinks = NewTabUtils.links;
+ gLinks.addProvider(DirectoryLinksProvider);
+ gLinks.populateCache();
+ yield new Promise(resolve => {
+ NewTabUtils.allPages.register({
+ observe: _ => _,
+ update() {
+ NewTabUtils.allPages.unregister(this);
+ resolve();
+ }
+ });
+ });
+
+ function synthesizeAction(action) {
+ DirectoryLinksProvider.reportSitesAction([{
+ link: {
+ targetedSite: targets[0],
+ url: testUrl
+ }
+ }], action, 0);
+ }
+
+ function checkFirstTypeAndLength(type, length) {
+ let links = gLinks.getLinks();
+ do_check_eq(links[0].type, type);
+ do_check_eq(links.length, length);
+ }
+
+ // Make sure the link disappears after the first click
+ checkFirstTypeAndLength("affiliate", 2);
+ synthesizeAction("view");
+ checkFirstTypeAndLength("affiliate", 2);
+ synthesizeAction("click");
+ checkFirstTypeAndLength("organic", 1);
+
+ // Cleanup.
+ NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+ DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+ gLinks.removeProvider(DirectoryLinksProvider);
+ DirectoryLinksProvider.removeObserver(gLinks);
+ Services.prefs.setCharPref(kPingUrlPref, kPingUrl);
+});
+
+add_task(function* test_fetchAndCacheLinks_local() {
+ yield DirectoryLinksProvider.init();
+ yield cleanJsonFile();
+ // Trigger cache of data or chrome uri files in profD
+ yield DirectoryLinksProvider._fetchAndCacheLinks(kTestURL);
+ let data = yield readJsonFile();
+ isIdentical(data, kURLData);
+});
+
+add_task(function* test_fetchAndCacheLinks_remote() {
+ yield DirectoryLinksProvider.init();
+ yield cleanJsonFile();
+ // this must trigger directory links json download and save it to cache file
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kExampleURL + "%LOCALE%");
+ do_check_eq(gLastRequestPath, kExamplePath + "en-US");
+ let data = yield readJsonFile();
+ isIdentical(data, kHttpHandlerData[kExamplePath]);
+});
+
+add_task(function* test_fetchAndCacheLinks_malformedURI() {
+ yield DirectoryLinksProvider.init();
+ yield cleanJsonFile();
+ let someJunk = "some junk";
+ try {
+ yield DirectoryLinksProvider._fetchAndCacheLinks(someJunk);
+ do_throw("Malformed URIs should fail")
+ } catch (e) {
+ do_check_eq(e, "Error fetching " + someJunk)
+ }
+
+ // File should be empty.
+ let data = yield readJsonFile();
+ isIdentical(data, "");
+});
+
+add_task(function* test_fetchAndCacheLinks_unknownHost() {
+ yield DirectoryLinksProvider.init();
+ yield cleanJsonFile();
+ let nonExistentServer = "http://localhost:56789/";
+ try {
+ yield DirectoryLinksProvider._fetchAndCacheLinks(nonExistentServer);
+ do_throw("BAD URIs should fail");
+ } catch (e) {
+ do_check_true(e.startsWith("Fetching " + nonExistentServer + " results in error code: "))
+ }
+
+ // File should be empty.
+ let data = yield readJsonFile();
+ isIdentical(data, "");
+});
+
+add_task(function* test_fetchAndCacheLinks_non200Status() {
+ yield DirectoryLinksProvider.init();
+ yield cleanJsonFile();
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kFailURL);
+ do_check_eq(gLastRequestPath, kFailPath);
+ let data = yield readJsonFile();
+ isIdentical(data, {});
+});
+
+// To test onManyLinksChanged observer, trigger a fetch
+add_task(function* test_DirectoryLinksProvider__linkObservers() {
+ yield DirectoryLinksProvider.init();
+
+ let testObserver = new LinksChangeObserver();
+ DirectoryLinksProvider.addObserver(testObserver);
+ do_check_eq(DirectoryLinksProvider._observers.size, 1);
+ DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(true);
+
+ yield testObserver.deferred.promise;
+ DirectoryLinksProvider._removeObservers();
+ do_check_eq(DirectoryLinksProvider._observers.size, 0);
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider__prefObserver_url() {
+ yield promiseSetupDirectoryLinksProvider({linksURL: kTestURL});
+
+ let links = yield fetchData();
+ do_check_eq(links.length, 1);
+ let expectedData = [{url: "http://example.com", title: "LocalSource", frecency: DIRECTORY_FRECENCY, lastVisitDate: 1}];
+ isIdentical(links, expectedData);
+
+ // tests these 2 things:
+ // 1. _linksURL is properly set after the pref change
+ // 2. invalid source url is correctly handled
+ let exampleUrl = 'http://localhost:56789/bad';
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, exampleUrl);
+ do_check_eq(DirectoryLinksProvider._linksURL, exampleUrl);
+
+ // since the download fail, the directory file must remain the same
+ let newLinks = yield fetchData();
+ isIdentical(newLinks, expectedData);
+
+ // now remove the file, and re-download
+ yield cleanJsonFile();
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, exampleUrl + " ");
+ // we now should see empty links
+ newLinks = yield fetchData();
+ isIdentical(newLinks, []);
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider_getLinks_noDirectoryData() {
+ let data = {
+ "directory": [],
+ };
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ let links = yield fetchData();
+ do_check_eq(links.length, 0);
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider_getLinks_badData() {
+ let data = {
+ "en-US": {
+ "en-US": [{url: "http://example.com", title: "US"}],
+ },
+ };
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ // Make sure we get nothing for incorrectly formatted data
+ let links = yield fetchData();
+ do_check_eq(links.length, 0);
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function test_DirectoryLinksProvider_needsDownload() {
+ // test timestamping
+ DirectoryLinksProvider._lastDownloadMS = 0;
+ do_check_true(DirectoryLinksProvider._needsDownload);
+ DirectoryLinksProvider._lastDownloadMS = Date.now();
+ do_check_false(DirectoryLinksProvider._needsDownload);
+ DirectoryLinksProvider._lastDownloadMS = Date.now() - (60*60*24 + 1)*1000;
+ do_check_true(DirectoryLinksProvider._needsDownload);
+ DirectoryLinksProvider._lastDownloadMS = 0;
+});
+
+add_task(function* test_DirectoryLinksProvider_fetchAndCacheLinksIfNecessary() {
+ yield DirectoryLinksProvider.init();
+ yield cleanJsonFile();
+ // explicitly change source url to cause the download during setup
+ yield promiseSetupDirectoryLinksProvider({linksURL: kTestURL+" "});
+ yield DirectoryLinksProvider._fetchAndCacheLinksIfNecessary();
+
+ // inspect lastDownloadMS timestamp which should be 5 seconds less then now()
+ let lastDownloadMS = DirectoryLinksProvider._lastDownloadMS;
+ do_check_true((Date.now() - lastDownloadMS) < 5000);
+
+ // we should have fetched a new file during setup
+ let data = yield readJsonFile();
+ isIdentical(data, kURLData);
+
+ // attempt to download again - the timestamp should not change
+ yield DirectoryLinksProvider._fetchAndCacheLinksIfNecessary();
+ do_check_eq(DirectoryLinksProvider._lastDownloadMS, lastDownloadMS);
+
+ // clean the file and force the download
+ yield cleanJsonFile();
+ yield DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(true);
+ data = yield readJsonFile();
+ isIdentical(data, kURLData);
+
+ // make sure that failed download does not corrupt the file, nor changes lastDownloadMS
+ lastDownloadMS = DirectoryLinksProvider._lastDownloadMS;
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, "http://");
+ yield DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(true);
+ data = yield readJsonFile();
+ isIdentical(data, kURLData);
+ do_check_eq(DirectoryLinksProvider._lastDownloadMS, lastDownloadMS);
+
+ // _fetchAndCacheLinksIfNecessary must return same promise if download is in progress
+ let downloadPromise = DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(true);
+ let anotherPromise = DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(true);
+ do_check_true(downloadPromise === anotherPromise);
+ yield downloadPromise;
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider_fetchDirectoryOnPrefChange() {
+ yield DirectoryLinksProvider.init();
+
+ let testObserver = new LinksChangeObserver();
+ DirectoryLinksProvider.addObserver(testObserver);
+
+ yield cleanJsonFile();
+ // ensure that provider does not think it needs to download
+ do_check_false(DirectoryLinksProvider._needsDownload);
+
+ // change the source URL, which should force directory download
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kExampleURL);
+ // then wait for testObserver to fire and test that json is downloaded
+ yield testObserver.deferred.promise;
+ do_check_eq(gLastRequestPath, kExamplePath);
+ let data = yield readJsonFile();
+ isIdentical(data, kHttpHandlerData[kExamplePath]);
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider_fetchDirectoryOnShow() {
+ yield promiseSetupDirectoryLinksProvider();
+
+ // set lastdownload to 0 to make DirectoryLinksProvider want to download
+ DirectoryLinksProvider._lastDownloadMS = 0;
+ do_check_true(DirectoryLinksProvider._needsDownload);
+
+ // download should happen on view
+ yield DirectoryLinksProvider.reportSitesAction([], "view");
+ do_check_true(DirectoryLinksProvider._lastDownloadMS != 0);
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider_fetchDirectoryOnInit() {
+ // ensure preferences are set to defaults
+ yield promiseSetupDirectoryLinksProvider();
+ // now clean to provider, so we can init it again
+ yield promiseCleanDirectoryLinksProvider();
+
+ yield cleanJsonFile();
+ yield DirectoryLinksProvider.init();
+ let data = yield readJsonFile();
+ isIdentical(data, kURLData);
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider_getLinksFromCorruptedFile() {
+ yield promiseSetupDirectoryLinksProvider();
+
+ // write bogus json to a file and attempt to fetch from it
+ let directoryLinksFilePath = OS.Path.join(OS.Constants.Path.profileDir, DIRECTORY_LINKS_FILE);
+ yield OS.File.writeAtomic(directoryLinksFilePath, '{"en-US":');
+ let data = yield fetchData();
+ isIdentical(data, []);
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider_getAllowedLinks() {
+ let data = {"directory": [
+ {url: "ftp://example.com"},
+ {url: "http://example.net"},
+ {url: "javascript:5"},
+ {url: "https://example.com"},
+ {url: "httpJUNKjavascript:42"},
+ {url: "data:text/plain,hi"},
+ {url: "http/bork:eh"},
+ ]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ let links = yield fetchData();
+ do_check_eq(links.length, 2);
+
+ // The only remaining url should be http and https
+ do_check_eq(links[0].url, data["directory"][1].url);
+ do_check_eq(links[1].url, data["directory"][3].url);
+});
+
+add_task(function* test_DirectoryLinksProvider_getAllowedImages() {
+ let data = {"directory": [
+ {url: "http://example.com", imageURI: "ftp://example.com"},
+ {url: "http://example.com", imageURI: "http://example.net"},
+ {url: "http://example.com", imageURI: "javascript:5"},
+ {url: "http://example.com", imageURI: "https://example.com"},
+ {url: "http://example.com", imageURI: "httpJUNKjavascript:42"},
+ {url: "http://example.com", imageURI: "data:text/plain,hi"},
+ {url: "http://example.com", imageURI: "http/bork:eh"},
+ ]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ let links = yield fetchData();
+ do_check_eq(links.length, 2);
+
+ // The only remaining images should be https and data
+ do_check_eq(links[0].imageURI, data["directory"][3].imageURI);
+ do_check_eq(links[1].imageURI, data["directory"][5].imageURI);
+});
+
+add_task(function* test_DirectoryLinksProvider_getAllowedImages_base() {
+ let data = {"directory": [
+ {url: "http://example1.com", imageURI: "https://example.com"},
+ {url: "http://example2.com", imageURI: "https://tiles.cdn.mozilla.net"},
+ {url: "http://example3.com", imageURI: "https://tiles2.cdn.mozilla.net"},
+ {url: "http://example4.com", enhancedImageURI: "https://mozilla.net"},
+ {url: "http://example5.com", imageURI: "data:text/plain,hi"},
+ ]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ // Pretend we're using the default pref to trigger base matching
+ DirectoryLinksProvider.__linksURLModified = false;
+
+ let links = yield fetchData();
+ do_check_eq(links.length, 4);
+
+ // The only remaining images should be https with mozilla.net or data URI
+ do_check_eq(links[0].url, data["directory"][1].url);
+ do_check_eq(links[1].url, data["directory"][2].url);
+ do_check_eq(links[2].url, data["directory"][3].url);
+ do_check_eq(links[3].url, data["directory"][4].url);
+});
+
+add_task(function* test_DirectoryLinksProvider_getAllowedEnhancedImages() {
+ let data = {"directory": [
+ {url: "http://example.com", enhancedImageURI: "ftp://example.com"},
+ {url: "http://example.com", enhancedImageURI: "http://example.net"},
+ {url: "http://example.com", enhancedImageURI: "javascript:5"},
+ {url: "http://example.com", enhancedImageURI: "https://example.com"},
+ {url: "http://example.com", enhancedImageURI: "httpJUNKjavascript:42"},
+ {url: "http://example.com", enhancedImageURI: "data:text/plain,hi"},
+ {url: "http://example.com", enhancedImageURI: "http/bork:eh"},
+ ]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ let links = yield fetchData();
+ do_check_eq(links.length, 2);
+
+ // The only remaining enhancedImages should be http and https and data
+ do_check_eq(links[0].enhancedImageURI, data["directory"][3].enhancedImageURI);
+ do_check_eq(links[1].enhancedImageURI, data["directory"][5].enhancedImageURI);
+});
+
+add_task(function* test_DirectoryLinksProvider_getEnhancedLink() {
+ let data = {"enhanced": [
+ {url: "http://example.net", enhancedImageURI: "data:,net1"},
+ {url: "http://example.com", enhancedImageURI: "data:,com1"},
+ {url: "http://example.com", enhancedImageURI: "data:,com2"},
+ ]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ let links = yield fetchData();
+ do_check_eq(links.length, 0); // There are no directory links.
+
+ function checkEnhanced(url, image) {
+ let enhanced = DirectoryLinksProvider.getEnhancedLink({url: url});
+ do_check_eq(enhanced && enhanced.enhancedImageURI, image);
+ }
+
+ // Get the expected image for the same site
+ checkEnhanced("http://example.net/", "data:,net1");
+ checkEnhanced("http://example.net/path", "data:,net1");
+ checkEnhanced("https://www.example.net/", "data:,net1");
+ checkEnhanced("https://www3.example.net/", "data:,net1");
+
+ // Get the image of the last entry
+ checkEnhanced("http://example.com", "data:,com2");
+
+ // Get the inline enhanced image
+ let inline = DirectoryLinksProvider.getEnhancedLink({
+ url: "http://example.com/echo",
+ enhancedImageURI: "data:,echo",
+ });
+ do_check_eq(inline.enhancedImageURI, "data:,echo");
+ do_check_eq(inline.url, "http://example.com/echo");
+
+ // Undefined for not enhanced
+ checkEnhanced("http://sub.example.net/", undefined);
+ checkEnhanced("http://example.org", undefined);
+ checkEnhanced("http://localhost", undefined);
+ checkEnhanced("http://127.0.0.1", undefined);
+
+ // Make sure old data is not cached
+ data = {"enhanced": [
+ {url: "http://example.com", enhancedImageURI: "data:,fresh"},
+ ]};
+ dataURI = 'data:application/json,' + JSON.stringify(data);
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+ links = yield fetchData();
+ do_check_eq(links.length, 0); // There are no directory links.
+ checkEnhanced("http://example.net", undefined);
+ checkEnhanced("http://example.com", "data:,fresh");
+});
+
+add_task(function* test_DirectoryLinksProvider_enhancedURIs() {
+ let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+ NewTabUtils.isTopPlacesSite = () => true;
+ let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+ DirectoryLinksProvider._getCurrentTopSiteCount = () => 8;
+
+ let data = {
+ "suggested": [
+ {url: "http://example.net", enhancedImageURI: "data:,net1", title:"SuggestedTitle", adgroup_name: "Test", frecent_sites: ["test.com"]}
+ ],
+ "directory": [
+ {url: "http://example.net", enhancedImageURI: "data:,net2", title:"DirectoryTitle"}
+ ]
+ };
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+
+ // Wait for links to get loaded
+ let gLinks = NewTabUtils.links;
+ gLinks.addProvider(DirectoryLinksProvider);
+ gLinks.populateCache();
+ yield new Promise(resolve => {
+ NewTabUtils.allPages.register({
+ observe: _ => _,
+ update() {
+ NewTabUtils.allPages.unregister(this);
+ resolve();
+ }
+ });
+ });
+
+ // Check that we've saved the directory tile.
+ let links = yield fetchData();
+ do_check_eq(links.length, 1);
+ do_check_eq(links[0].title, "DirectoryTitle");
+ do_check_eq(links[0].enhancedImageURI, "data:,net2");
+
+ // Check that the suggested tile with the same URL replaces the directory tile.
+ links = gLinks.getLinks();
+ do_check_eq(links.length, 1);
+ do_check_eq(links[0].title, "SuggestedTitle");
+ do_check_eq(links[0].enhancedImageURI, "data:,net1");
+
+ // Cleanup.
+ NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+ DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+ gLinks.removeProvider(DirectoryLinksProvider);
+});
+
+add_task(function test_DirectoryLinksProvider_setDefaultEnhanced() {
+ function checkDefault(expected) {
+ Services.prefs.clearUserPref(kNewtabEnhancedPref);
+ do_check_eq(Services.prefs.getBoolPref(kNewtabEnhancedPref), expected);
+ }
+
+ // Use the default donottrack prefs (enabled = false)
+ Services.prefs.clearUserPref("privacy.donottrackheader.enabled");
+ checkDefault(true);
+
+ // Turn on DNT - no track
+ Services.prefs.setBoolPref("privacy.donottrackheader.enabled", true);
+ checkDefault(false);
+
+ // Turn off DNT header
+ Services.prefs.clearUserPref("privacy.donottrackheader.enabled");
+ checkDefault(true);
+
+ // Clean up
+ Services.prefs.clearUserPref("privacy.donottrackheader.value");
+});
+
+add_task(function* test_timeSensetiveSuggestedTiles() {
+ // make tile json with start and end dates
+ let testStartTime = Date.now();
+ // start date is now + 1 seconds
+ let startDate = new Date(testStartTime + 1000);
+ // end date is now + 3 seconds
+ let endDate = new Date(testStartTime + 3000);
+ let suggestedTile = Object.assign({
+ time_limits: {
+ start: startDate.toISOString(),
+ end: endDate.toISOString(),
+ }
+ }, suggestedTile1);
+
+ // Initial setup
+ let topSites = ["site0.com", "1040.com", "site2.com", "hrblock.com", "site4.com", "freetaxusa.com", "site6.com"];
+ let data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+
+ let testObserver = new TestTimingRun();
+ DirectoryLinksProvider.addObserver(testObserver);
+
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+ let links = yield fetchData();
+
+ let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+ NewTabUtils.isTopPlacesSite = function(site) {
+ return topSites.indexOf(site) >= 0;
+ }
+
+ let origGetProviderLinks = NewTabUtils.getProviderLinks;
+ NewTabUtils.getProviderLinks = function(provider) {
+ return links;
+ }
+
+ let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+ DirectoryLinksProvider._getCurrentTopSiteCount = () => 8;
+
+ do_check_eq(DirectoryLinksProvider._updateSuggestedTile(), undefined);
+
+ // this tester will fire twice: when start limit is reached and when tile link
+ // is removed upon end of the campaign, in which case deleteFlag will be set
+ function TestTimingRun() {
+ this.promise = new Promise(resolve => {
+ this.onLinkChanged = (directoryLinksProvider, link, ignoreFlag, deleteFlag) => {
+ // if we are not deleting, add link to links, so we can catch it's removal
+ if (!deleteFlag) {
+ links.unshift(link);
+ }
+
+ isIdentical([...DirectoryLinksProvider._topSitesWithSuggestedLinks], ["hrblock.com", "1040.com"]);
+ do_check_eq(link.frecency, SUGGESTED_FRECENCY);
+ do_check_eq(link.type, "affiliate");
+ do_check_eq(link.url, suggestedTile.url);
+ let timeDelta = Date.now() - testStartTime;
+ if (!deleteFlag) {
+ // this is start timeout corresponding to campaign start
+ // a seconds must pass and targetedSite must be set
+ do_print("TESTING START timeDelta: " + timeDelta);
+ do_check_true(timeDelta >= 1000 / 2); // check for at least half time
+ do_check_eq(link.targetedSite, "hrblock.com");
+ do_check_true(DirectoryLinksProvider._campaignTimeoutID);
+ }
+ else {
+ // this is the campaign end timeout, so 3 seconds must pass
+ // and timeout should be cleared
+ do_print("TESTING END timeDelta: " + timeDelta);
+ do_check_true(timeDelta >= 3000 / 2); // check for at least half time
+ do_check_false(link.targetedSite);
+ do_check_false(DirectoryLinksProvider._campaignTimeoutID);
+ resolve();
+ }
+ };
+ });
+ }
+
+ // _updateSuggestedTile() is called when fetching directory links.
+ yield testObserver.promise;
+ DirectoryLinksProvider.removeObserver(testObserver);
+
+ // shoudl suggest nothing
+ do_check_eq(DirectoryLinksProvider._updateSuggestedTile(), undefined);
+
+ // set links back to contain directory tile only
+ links.shift();
+
+ // drop the end time - we should pick up the tile
+ suggestedTile.time_limits.end = null;
+ data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+ dataURI = 'data:application/json,' + JSON.stringify(data);
+
+ // redownload json and getLinks to force time recomputation
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, dataURI);
+
+ // ensure that there's a link returned by _updateSuggestedTile and no timeout
+ let deferred = Promise.defer();
+ DirectoryLinksProvider.getLinks(() => {
+ let link = DirectoryLinksProvider._updateSuggestedTile();
+ // we should have a suggested tile and no timeout
+ do_check_eq(link.type, "affiliate");
+ do_check_eq(link.url, suggestedTile.url);
+ do_check_false(DirectoryLinksProvider._campaignTimeoutID);
+ deferred.resolve();
+ });
+ yield deferred.promise;
+
+ // repeat the test for end time only
+ suggestedTile.time_limits.start = null;
+ suggestedTile.time_limits.end = (new Date(Date.now() + 3000)).toISOString();
+
+ data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+ dataURI = 'data:application/json,' + JSON.stringify(data);
+
+ // redownload json and call getLinks() to force time recomputation
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, dataURI);
+
+ // ensure that there's a link returned by _updateSuggestedTile and timeout set
+ deferred = Promise.defer();
+ DirectoryLinksProvider.getLinks(() => {
+ let link = DirectoryLinksProvider._updateSuggestedTile();
+ // we should have a suggested tile and timeout set
+ do_check_eq(link.type, "affiliate");
+ do_check_eq(link.url, suggestedTile.url);
+ do_check_true(DirectoryLinksProvider._campaignTimeoutID);
+ DirectoryLinksProvider._clearCampaignTimeout();
+ deferred.resolve();
+ });
+ yield deferred.promise;
+
+ // Cleanup
+ yield promiseCleanDirectoryLinksProvider();
+ NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+ NewTabUtils.getProviderLinks = origGetProviderLinks;
+ DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+});
+
+add_task(function test_setupStartEndTime() {
+ let currentTime = Date.now();
+ let dt = new Date(currentTime);
+ let link = {
+ time_limits: {
+ start: dt.toISOString()
+ }
+ };
+
+ // test ISO translation
+ DirectoryLinksProvider._setupStartEndTime(link);
+ do_check_eq(link.startTime, currentTime);
+
+ // test localtime translation
+ let shiftedDate = new Date(currentTime - dt.getTimezoneOffset()*60*1000);
+ link.time_limits.start = shiftedDate.toISOString().replace(/Z$/, "");
+
+ DirectoryLinksProvider._setupStartEndTime(link);
+ do_check_eq(link.startTime, currentTime);
+
+ // throw some garbage into date string
+ delete link.startTime;
+ link.time_limits.start = "no date"
+ DirectoryLinksProvider._setupStartEndTime(link);
+ do_check_false(link.startTime);
+
+ link.time_limits.start = "2015-99999-01T00:00:00"
+ DirectoryLinksProvider._setupStartEndTime(link);
+ do_check_false(link.startTime);
+
+ link.time_limits.start = "20150501T00:00:00"
+ DirectoryLinksProvider._setupStartEndTime(link);
+ do_check_false(link.startTime);
+});
+
+add_task(function* test_DirectoryLinksProvider_frequencyCapSetup() {
+ yield promiseSetupDirectoryLinksProvider();
+ yield DirectoryLinksProvider.init();
+
+ yield promiseCleanDirectoryLinksProvider();
+ yield DirectoryLinksProvider._readFrequencyCapFile();
+ isIdentical(DirectoryLinksProvider._frequencyCaps, {});
+
+ // setup few links
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: "1",
+ });
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: "2",
+ frequency_caps: {daily: 1, total: 2}
+ });
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: "3",
+ frequency_caps: {total: 2}
+ });
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: "4",
+ frequency_caps: {daily: 1}
+ });
+ let freqCapsObject = DirectoryLinksProvider._frequencyCaps;
+ let capObject = freqCapsObject["1"];
+ let defaultDaily = capObject.dailyCap;
+ let defaultTotal = capObject.totalCap;
+ // check if we have defaults set
+ do_check_true(capObject.dailyCap > 0);
+ do_check_true(capObject.totalCap > 0);
+ // check if defaults are properly handled
+ do_check_eq(freqCapsObject["2"].dailyCap, 1);
+ do_check_eq(freqCapsObject["2"].totalCap, 2);
+ do_check_eq(freqCapsObject["3"].dailyCap, defaultDaily);
+ do_check_eq(freqCapsObject["3"].totalCap, 2);
+ do_check_eq(freqCapsObject["4"].dailyCap, 1);
+ do_check_eq(freqCapsObject["4"].totalCap, defaultTotal);
+
+ // write object to file
+ yield DirectoryLinksProvider._writeFrequencyCapFile();
+ // empty out freqCapsObject and read file back
+ DirectoryLinksProvider._frequencyCaps = {};
+ yield DirectoryLinksProvider._readFrequencyCapFile();
+ // re-ran tests - they should all pass
+ do_check_eq(freqCapsObject["2"].dailyCap, 1);
+ do_check_eq(freqCapsObject["2"].totalCap, 2);
+ do_check_eq(freqCapsObject["3"].dailyCap, defaultDaily);
+ do_check_eq(freqCapsObject["3"].totalCap, 2);
+ do_check_eq(freqCapsObject["4"].dailyCap, 1);
+ do_check_eq(freqCapsObject["4"].totalCap, defaultTotal);
+
+ // wait a second and prune frequency caps
+ yield new Promise(resolve => {
+ setTimeout(resolve, 1100);
+ });
+
+ // update one link and create another
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: "3",
+ frequency_caps: {daily: 1, total: 2}
+ });
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: "7",
+ frequency_caps: {daily: 1, total: 2}
+ });
+ // now prune the ones that have been in the object longer than 1 second
+ DirectoryLinksProvider._pruneFrequencyCapUrls(1000);
+ // make sure all keys but "3" and "7" are deleted
+ Object.keys(DirectoryLinksProvider._frequencyCaps).forEach(key => {
+ do_check_true(key == "3" || key == "7");
+ });
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider_getFrequencyCapLogic() {
+ yield promiseSetupDirectoryLinksProvider();
+ yield DirectoryLinksProvider.init();
+
+ // setup suggested links
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: "1",
+ frequency_caps: {daily: 2, total: 4}
+ });
+
+ do_check_true(DirectoryLinksProvider._testFrequencyCapLimits("1"));
+ // exhaust daily views
+ DirectoryLinksProvider._addFrequencyCapView("1")
+ do_check_true(DirectoryLinksProvider._testFrequencyCapLimits("1"));
+ DirectoryLinksProvider._addFrequencyCapView("1")
+ do_check_false(DirectoryLinksProvider._testFrequencyCapLimits("1"));
+
+ // now step into the furture
+ let _wasTodayOrig = DirectoryLinksProvider._wasToday;
+ DirectoryLinksProvider._wasToday = function () { return false; }
+ // exhaust total views
+ DirectoryLinksProvider._addFrequencyCapView("1")
+ do_check_true(DirectoryLinksProvider._testFrequencyCapLimits("1"));
+ DirectoryLinksProvider._addFrequencyCapView("1")
+ // reached totalViews 4, should return false
+ do_check_false(DirectoryLinksProvider._testFrequencyCapLimits("1"));
+
+ // add more views by updating configuration
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: "1",
+ frequency_caps: {daily: 5, total: 10}
+ });
+ // should be true, since we have more total views
+ do_check_true(DirectoryLinksProvider._testFrequencyCapLimits("1"));
+
+ // set click flag
+ DirectoryLinksProvider._setFrequencyCapClick("1");
+ // always false after click
+ do_check_false(DirectoryLinksProvider._testFrequencyCapLimits("1"));
+
+ // use unknown urls and ensure nothing breaks
+ DirectoryLinksProvider._addFrequencyCapView("nosuch.url");
+ DirectoryLinksProvider._setFrequencyCapClick("nosuch.url");
+ // testing unknown url should always return false
+ do_check_false(DirectoryLinksProvider._testFrequencyCapLimits("nosuch.url"));
+
+ // reset _wasToday back to original function
+ DirectoryLinksProvider._wasToday = _wasTodayOrig;
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider_getFrequencyCapReportSiteAction() {
+ yield promiseSetupDirectoryLinksProvider();
+ yield DirectoryLinksProvider.init();
+
+ // setup suggested links
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: "bar.com",
+ frequency_caps: {daily: 2, total: 4}
+ });
+
+ do_check_true(DirectoryLinksProvider._testFrequencyCapLimits("bar.com"));
+ // report site action
+ yield DirectoryLinksProvider.reportSitesAction([{
+ link: {
+ targetedSite: "foo.com",
+ url: "bar.com"
+ },
+ isPinned: function() { return false; },
+ }], "view", 0);
+
+ // read file content and ensure that view counters are updated
+ let data = yield readJsonFile(DirectoryLinksProvider._frequencyCapFilePath);
+ do_check_eq(data["bar.com"].dailyViews, 1);
+ do_check_eq(data["bar.com"].totalViews, 1);
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_DirectoryLinksProvider_ClickRemoval() {
+ yield promiseSetupDirectoryLinksProvider();
+ yield DirectoryLinksProvider.init();
+ let landingUrl = "http://foo.com";
+
+ // setup suggested links
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: landingUrl,
+ frequency_caps: {daily: 2, total: 4}
+ });
+
+ // add views
+ DirectoryLinksProvider._addFrequencyCapView(landingUrl)
+ DirectoryLinksProvider._addFrequencyCapView(landingUrl)
+ // make a click
+ DirectoryLinksProvider._setFrequencyCapClick(landingUrl);
+
+ // views must be 2 and click must be set
+ do_check_eq(DirectoryLinksProvider._frequencyCaps[landingUrl].totalViews, 2);
+ do_check_true(DirectoryLinksProvider._frequencyCaps[landingUrl].clicked);
+
+ // now insert a visit into places
+ yield new Promise(resolve => {
+ PlacesUtils.asyncHistory.updatePlaces(
+ {
+ uri: NetUtil.newURI(landingUrl),
+ title: "HELLO",
+ visits: [{
+ visitDate: Date.now()*1000,
+ transitionType: Ci.nsINavHistoryService.TRANSITION_LINK
+ }]
+ },
+ {
+ handleError: function () { do_check_true(false); },
+ handleResult: function () {},
+ handleCompletion: function () { resolve(); }
+ }
+ );
+ });
+
+ function UrlDeletionTester() {
+ this.promise = new Promise(resolve => {
+ this.onDeleteURI = (directoryLinksProvider, link) => {
+ resolve();
+ };
+ this.onClearHistory = (directoryLinksProvider) => {
+ resolve();
+ };
+ });
+ }
+
+ let testObserver = new UrlDeletionTester();
+ DirectoryLinksProvider.addObserver(testObserver);
+
+ PlacesUtils.bhistory.removePage(NetUtil.newURI(landingUrl));
+ yield testObserver.promise;
+ DirectoryLinksProvider.removeObserver(testObserver);
+ // views must be 2 and click should not exist
+ do_check_eq(DirectoryLinksProvider._frequencyCaps[landingUrl].totalViews, 2);
+ do_check_false(DirectoryLinksProvider._frequencyCaps[landingUrl].hasOwnProperty("clicked"));
+
+ // verify that disk written data is kosher
+ let data = yield readJsonFile(DirectoryLinksProvider._frequencyCapFilePath);
+ do_check_eq(data[landingUrl].totalViews, 2);
+ do_check_false(data[landingUrl].hasOwnProperty("clicked"));
+
+ // now test clear history
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: landingUrl,
+ frequency_caps: {daily: 2, total: 4}
+ });
+ DirectoryLinksProvider._updateFrequencyCapSettings({
+ url: "http://bar.com",
+ frequency_caps: {daily: 2, total: 4}
+ });
+
+ DirectoryLinksProvider._setFrequencyCapClick(landingUrl);
+ DirectoryLinksProvider._setFrequencyCapClick("http://bar.com");
+ // both tiles must have clicked
+ do_check_true(DirectoryLinksProvider._frequencyCaps[landingUrl].clicked);
+ do_check_true(DirectoryLinksProvider._frequencyCaps["http://bar.com"].clicked);
+
+ testObserver = new UrlDeletionTester();
+ DirectoryLinksProvider.addObserver(testObserver);
+ yield PlacesTestUtils.clearHistory();
+
+ yield testObserver.promise;
+ DirectoryLinksProvider.removeObserver(testObserver);
+ // no clicks should remain in the cap object
+ do_check_false(DirectoryLinksProvider._frequencyCaps[landingUrl].hasOwnProperty("clicked"));
+ do_check_false(DirectoryLinksProvider._frequencyCaps["http://bar.com"].hasOwnProperty("clicked"));
+
+ // verify that disk written data is kosher
+ data = yield readJsonFile(DirectoryLinksProvider._frequencyCapFilePath);
+ do_check_false(data[landingUrl].hasOwnProperty("clicked"));
+ do_check_false(data["http://bar.com"].hasOwnProperty("clicked"));
+
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function test_DirectoryLinksProvider_anonymous() {
+ do_check_true(DirectoryLinksProvider._newXHR().mozAnon);
+});
+
+add_task(function* test_sanitizeExplanation() {
+ // Note: this is a basic test to ensure we applied sanitization to the link explanation.
+ // Full testing for appropriate sanitization is done in parser/xml/test/unit/test_sanitizer.js.
+ let data = {"suggested": [suggestedTile5]};
+ let dataURI = 'data:application/json,' + encodeURIComponent(JSON.stringify(data));
+
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+ yield fetchData();
+
+ let suggestedSites = [...DirectoryLinksProvider._suggestedLinks.keys()];
+ do_check_eq(suggestedSites.indexOf("eviltarget.com"), 0);
+ do_check_eq(suggestedSites.length, 1);
+
+ let suggestedLink = [...DirectoryLinksProvider._suggestedLinks.get(suggestedSites[0]).values()][0];
+ do_check_eq(suggestedLink.explanation, "This is an evil tile X muhahaha");
+ do_check_eq(suggestedLink.targetedName, "WE ARE EVIL ");
+});
+
+add_task(function* test_inadjecentSites() {
+ let suggestedTile = Object.assign({
+ check_inadjacency: true
+ }, suggestedTile1);
+
+ // Initial setup
+ let topSites = ["1040.com", "site2.com", "hrblock.com", "site4.com", "freetaxusa.com", "site6.com"];
+ let data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+
+ let testObserver = new TestFirstRun();
+ DirectoryLinksProvider.addObserver(testObserver);
+
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+ let links = yield fetchData();
+
+ let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+ NewTabUtils.isTopPlacesSite = function(site) {
+ return topSites.indexOf(site) >= 0;
+ }
+
+ let origGetProviderLinks = NewTabUtils.getProviderLinks;
+ NewTabUtils.getProviderLinks = function(provider) {
+ return links;
+ }
+
+ let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+ DirectoryLinksProvider._getCurrentTopSiteCount = () => {
+ origCurrentTopSiteCount.apply(DirectoryLinksProvider);
+ return 8;
+ };
+
+ // store oroginal inadjacent sites url
+ let origInadjacentSitesUrl = DirectoryLinksProvider._inadjacentSitesUrl;
+
+ // loading inadjacent sites list function
+ function setInadjacentSites(sites) {
+ let badSiteB64 = [];
+ sites.forEach(site => {
+ badSiteB64.push(DirectoryLinksProvider._generateHash(site));
+ });
+ let theList = {"domains": badSiteB64};
+ let uri = 'data:application/json,' + JSON.stringify(theList);
+ DirectoryLinksProvider._inadjacentSitesUrl = uri;
+ return DirectoryLinksProvider._loadInadjacentSites();
+ }
+
+ // setup gLinks loader
+ let gLinks = NewTabUtils.links;
+ gLinks.addProvider(DirectoryLinksProvider);
+
+ function updateNewTabCache() {
+ gLinks.populateCache();
+ return new Promise(resolve => {
+ NewTabUtils.allPages.register({
+ observe: _ => _,
+ update() {
+ NewTabUtils.allPages.unregister(this);
+ resolve();
+ }
+ });
+ });
+ }
+
+ // no suggested file
+ do_check_eq(DirectoryLinksProvider._updateSuggestedTile(), undefined);
+ // _avoidInadjacentSites should be set, since link.check_inadjacency is on
+ do_check_true(DirectoryLinksProvider._avoidInadjacentSites);
+ // make sure example.com is included in inadjacent sites list
+ do_check_true(DirectoryLinksProvider._isInadjacentLink({baseDomain: "example.com"}));
+
+ function TestFirstRun() {
+ this.promise = new Promise(resolve => {
+ this.onLinkChanged = (directoryLinksProvider, link) => {
+ do_check_eq(link.url, suggestedTile.url);
+ do_check_eq(link.type, "affiliate");
+ resolve();
+ };
+ });
+ }
+
+ // Test first call to '_updateSuggestedTile()', called when fetching directory links.
+ yield testObserver.promise;
+ DirectoryLinksProvider.removeObserver(testObserver);
+
+ // update newtab cache
+ yield updateNewTabCache();
+ // this should have set
+ do_check_true(DirectoryLinksProvider._avoidInadjacentSites);
+
+ // there should be siggested link
+ let link = DirectoryLinksProvider._updateSuggestedTile();
+ do_check_eq(link.url, "http://turbotax.com");
+ // and it should have avoidInadjacentSites flag
+ do_check_true(link.check_inadjacency);
+
+ // make someothersite.com inadjacent
+ yield setInadjacentSites(["someothersite.com"]);
+
+ // there should be no suggested link
+ link = DirectoryLinksProvider._updateSuggestedTile();
+ do_check_false(link);
+ do_check_true(DirectoryLinksProvider._newTabHasInadjacentSite);
+
+ // _handleLinkChanged must return true on inadjacent site
+ do_check_true(DirectoryLinksProvider._handleLinkChanged({
+ url: "http://someothersite.com",
+ type: "history",
+ }));
+ // _handleLinkChanged must return false on ok site
+ do_check_false(DirectoryLinksProvider._handleLinkChanged({
+ url: "http://foobar.com",
+ type: "history",
+ }));
+
+ // change inadjacent list to sites not on newtab page
+ yield setInadjacentSites(["foo.com", "bar.com"]);
+
+ link = DirectoryLinksProvider._updateSuggestedTile();
+ // we should now have a link
+ do_check_true(link);
+ do_check_eq(link.url, "http://turbotax.com");
+
+ // make newtab offending again
+ yield setInadjacentSites(["someothersite.com", "foo.com"]);
+ // there should be no suggested link
+ link = DirectoryLinksProvider._updateSuggestedTile();
+ do_check_false(link);
+ do_check_true(DirectoryLinksProvider._newTabHasInadjacentSite);
+
+ // remove avoidInadjacentSites flag from suggested tile and reload json
+ delete suggestedTile.check_inadjacency;
+ data = {"suggested": [suggestedTile], "directory": [someOtherSite]};
+ dataURI = 'data:application/json,' + JSON.stringify(data);
+ yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, dataURI);
+ yield fetchData();
+
+ // inadjacent checking should be disabled
+ do_check_false(DirectoryLinksProvider._avoidInadjacentSites);
+ link = DirectoryLinksProvider._updateSuggestedTile();
+ do_check_true(link);
+ do_check_eq(link.url, "http://turbotax.com");
+ do_check_false(DirectoryLinksProvider._newTabHasInadjacentSite);
+
+ // _handleLinkChanged should return false now, even if newtab has bad site
+ do_check_false(DirectoryLinksProvider._handleLinkChanged({
+ url: "http://someothersite.com",
+ type: "history",
+ }));
+
+ // test _isInadjacentLink
+ do_check_true(DirectoryLinksProvider._isInadjacentLink({baseDomain: "someothersite.com"}));
+ do_check_false(DirectoryLinksProvider._isInadjacentLink({baseDomain: "bar.com"}));
+ do_check_true(DirectoryLinksProvider._isInadjacentLink({url: "http://www.someothersite.com"}));
+ do_check_false(DirectoryLinksProvider._isInadjacentLink({url: "http://www.bar.com"}));
+ // try to crash _isInadjacentLink
+ do_check_false(DirectoryLinksProvider._isInadjacentLink({baseDomain: ""}));
+ do_check_false(DirectoryLinksProvider._isInadjacentLink({url: ""}));
+ do_check_false(DirectoryLinksProvider._isInadjacentLink({url: "http://localhost:8081/"}));
+ do_check_false(DirectoryLinksProvider._isInadjacentLink({url: "abracodabra"}));
+ do_check_false(DirectoryLinksProvider._isInadjacentLink({}));
+
+ // test _checkForInadjacentSites
+ do_check_true(DirectoryLinksProvider._checkForInadjacentSites());
+
+ // Cleanup
+ gLinks.removeProvider(DirectoryLinksProvider);
+ DirectoryLinksProvider._inadjacentSitesUrl = origInadjacentSitesUrl;
+ NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+ NewTabUtils.getProviderLinks = origGetProviderLinks;
+ DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+ yield promiseCleanDirectoryLinksProvider();
+});
+
+add_task(function* test_blockSuggestedTiles() {
+ // Initial setup
+ let topSites = ["site0.com", "1040.com", "site2.com", "hrblock.com", "site4.com", "freetaxusa.com", "site6.com"];
+ let data = {"suggested": [suggestedTile1, suggestedTile2, suggestedTile3], "directory": [someOtherSite]};
+ let dataURI = 'data:application/json,' + JSON.stringify(data);
+
+ yield promiseSetupDirectoryLinksProvider({linksURL: dataURI});
+ let links = yield fetchData();
+
+ let origIsTopPlacesSite = NewTabUtils.isTopPlacesSite;
+ NewTabUtils.isTopPlacesSite = function(site) {
+ return topSites.indexOf(site) >= 0;
+ }
+
+ let origGetProviderLinks = NewTabUtils.getProviderLinks;
+ NewTabUtils.getProviderLinks = function(provider) {
+ return links;
+ }
+
+ let origCurrentTopSiteCount = DirectoryLinksProvider._getCurrentTopSiteCount;
+ DirectoryLinksProvider._getCurrentTopSiteCount = () => 8;
+
+ // load the links
+ yield new Promise(resolve => {
+ DirectoryLinksProvider.getLinks(resolve);
+ });
+
+ // ensure that tile is suggested
+ let suggestedLink = DirectoryLinksProvider._updateSuggestedTile();
+ do_check_true(suggestedLink.frecent_sites);
+
+ // block suggested tile in a regular way
+ DirectoryLinksProvider.reportSitesAction([{
+ isPinned: function() { return false; },
+ link: Object.assign({frecency: 1000}, suggestedLink)
+ }], "block", 0);
+
+ // suggested tile still must be recommended
+ suggestedLink = DirectoryLinksProvider._updateSuggestedTile();
+ do_check_true(suggestedLink.frecent_sites);
+
+ // timestamp suggested_block in the frequency cap object
+ DirectoryLinksProvider.handleSuggestedTileBlock();
+ // no more recommendations should be seen
+ do_check_eq(DirectoryLinksProvider._updateSuggestedTile(), undefined);
+
+ // move lastUpdated for suggested tile into the past
+ DirectoryLinksProvider._frequencyCaps["ignore://suggested_block"].lastUpdated = Date.now() - 25*60*60*1000;
+ // ensure that suggested tile updates again
+ suggestedLink = DirectoryLinksProvider._updateSuggestedTile();
+ do_check_true(suggestedLink.frecent_sites);
+
+ // Cleanup
+ yield promiseCleanDirectoryLinksProvider();
+ NewTabUtils.isTopPlacesSite = origIsTopPlacesSite;
+ NewTabUtils.getProviderLinks = origGetProviderLinks;
+ DirectoryLinksProvider._getCurrentTopSiteCount = origCurrentTopSiteCount;
+});
diff --git a/browser/modules/test/xpcshell/test_LaterRun.js b/browser/modules/test/xpcshell/test_LaterRun.js
new file mode 100644
index 000000000..7b45c7cd5
--- /dev/null
+++ b/browser/modules/test/xpcshell/test_LaterRun.js
@@ -0,0 +1,138 @@
+"use strict";
+
+const kEnabledPref = "browser.laterrun.enabled";
+const kPagePrefRoot = "browser.laterrun.pages.";
+const kSessionCountPref = "browser.laterrun.bookkeeping.sessionCount";
+const kProfileCreationTime = "browser.laterrun.bookkeeping.profileCreationTime";
+
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource:///modules/LaterRun.jsm");
+
+Services.prefs.setBoolPref(kEnabledPref, true);
+Components.utils.import("resource://testing-common/AppInfo.jsm");
+updateAppInfo();
+
+add_task(function* test_page_applies() {
+ Services.prefs.setCharPref(kPagePrefRoot + "test_LaterRun_unittest.url", "https://www.mozilla.org/%VENDOR%/%NAME%/%ID%/%VERSION%/");
+ Services.prefs.setIntPref(kPagePrefRoot + "test_LaterRun_unittest.minimumHoursSinceInstall", 10);
+ Services.prefs.setIntPref(kPagePrefRoot + "test_LaterRun_unittest.minimumSessionCount", 3);
+
+ let pages = LaterRun.readPages();
+ // We have to filter the pages because it's possible Firefox ships with other URLs
+ // that get included in this test.
+ pages = pages.filter(page => page.pref == kPagePrefRoot + "test_LaterRun_unittest.");
+ Assert.equal(pages.length, 1, "Got 1 page");
+ let page = pages[0];
+ Assert.equal(page.pref, kPagePrefRoot + "test_LaterRun_unittest.", "Should know its own pref");
+ Assert.equal(page.minimumHoursSinceInstall, 10, "Needs to have 10 hours since install");
+ Assert.equal(page.minimumSessionCount, 3, "Needs to have 3 sessions");
+ Assert.equal(page.requireBoth, false, "Either requirement is enough");
+ let expectedURL = "https://www.mozilla.org/" +
+ Services.appinfo.vendor + "/" +
+ Services.appinfo.name + "/" +
+ Services.appinfo.ID + "/" +
+ Services.appinfo.version + "/";
+ Assert.equal(page.url, expectedURL, "URL is stored correctly");
+
+ Assert.ok(page.applies({hoursSinceInstall: 1, sessionCount: 3}),
+ "Applies when session count has been met.");
+ Assert.ok(page.applies({hoursSinceInstall: 1, sessionCount: 4}),
+ "Applies when session count has been exceeded.");
+ Assert.ok(page.applies({hoursSinceInstall: 10, sessionCount: 2}),
+ "Applies when total session time has been met.");
+ Assert.ok(page.applies({hoursSinceInstall: 20, sessionCount: 2}),
+ "Applies when total session time has been exceeded.");
+ Assert.ok(page.applies({hoursSinceInstall: 10, sessionCount: 3}),
+ "Applies when both time and session count have been met.");
+ Assert.ok(!page.applies({hoursSinceInstall: 1, sessionCount: 1}),
+ "Does not apply when neither time and session count have been met.");
+
+ page.requireBoth = true;
+
+ Assert.ok(!page.applies({hoursSinceInstall: 1, sessionCount: 3}),
+ "Does not apply when only session count has been met.");
+ Assert.ok(!page.applies({hoursSinceInstall: 1, sessionCount: 4}),
+ "Does not apply when only session count has been exceeded.");
+ Assert.ok(!page.applies({hoursSinceInstall: 10, sessionCount: 2}),
+ "Does not apply when only total session time has been met.");
+ Assert.ok(!page.applies({hoursSinceInstall: 20, sessionCount: 2}),
+ "Does not apply when only total session time has been exceeded.");
+ Assert.ok(page.applies({hoursSinceInstall: 10, sessionCount: 3}),
+ "Applies when both time and session count have been met.");
+ Assert.ok(!page.applies({hoursSinceInstall: 1, sessionCount: 1}),
+ "Does not apply when neither time and session count have been met.");
+
+ // Check that pages that have run never apply:
+ Services.prefs.setBoolPref(kPagePrefRoot + "test_LaterRun_unittest.hasRun", true);
+ page.requireBoth = false;
+
+ Assert.ok(!page.applies({hoursSinceInstall: 1, sessionCount: 3}),
+ "Does not apply when page has already run (sessionCount equal).");
+ Assert.ok(!page.applies({hoursSinceInstall: 1, sessionCount: 4}),
+ "Does not apply when page has already run (sessionCount exceeding).");
+ Assert.ok(!page.applies({hoursSinceInstall: 10, sessionCount: 2}),
+ "Does not apply when page has already run (hoursSinceInstall equal).");
+ Assert.ok(!page.applies({hoursSinceInstall: 20, sessionCount: 2}),
+ "Does not apply when page has already run (hoursSinceInstall exceeding).");
+ Assert.ok(!page.applies({hoursSinceInstall: 10, sessionCount: 3}),
+ "Does not apply when page has already run (both criteria equal).");
+ Assert.ok(!page.applies({hoursSinceInstall: 1, sessionCount: 1}),
+ "Does not apply when page has already run (both criteria insufficient anyway).");
+
+ clearAllPagePrefs();
+});
+
+add_task(function* test_get_URL() {
+ Services.prefs.setIntPref(kProfileCreationTime, Math.floor((Date.now() - 11 * 60 * 60 * 1000) / 1000));
+ Services.prefs.setCharPref(kPagePrefRoot + "test_LaterRun_unittest.url", "https://www.mozilla.org/");
+ Services.prefs.setIntPref(kPagePrefRoot + "test_LaterRun_unittest.minimumHoursSinceInstall", 10);
+ Services.prefs.setIntPref(kPagePrefRoot + "test_LaterRun_unittest.minimumSessionCount", 3);
+ let pages = LaterRun.readPages();
+ // We have to filter the pages because it's possible Firefox ships with other URLs
+ // that get included in this test.
+ pages = pages.filter(page => page.pref == kPagePrefRoot + "test_LaterRun_unittest.");
+ Assert.equal(pages.length, 1, "Should only be 1 matching page");
+ let page = pages[0];
+ let url;
+ do {
+ url = LaterRun.getURL();
+ // We have to loop because it's possible Firefox ships with other URLs that get triggered by
+ // this test.
+ } while (url && url != "https://www.mozilla.org/");
+ Assert.equal(url, "https://www.mozilla.org/", "URL should be as expected when prefs are set.");
+ Assert.ok(Services.prefs.prefHasUserValue(kPagePrefRoot + "test_LaterRun_unittest.hasRun"), "Should have set pref");
+ Assert.ok(Services.prefs.getBoolPref(kPagePrefRoot + "test_LaterRun_unittest.hasRun"), "Should have set pref to true");
+ Assert.ok(page.hasRun, "Other page objects should know it has run, too.");
+
+ clearAllPagePrefs();
+});
+
+add_task(function* test_insecure_urls() {
+ Services.prefs.setCharPref(kPagePrefRoot + "test_LaterRun_unittest.url", "http://www.mozilla.org/");
+ Services.prefs.setIntPref(kPagePrefRoot + "test_LaterRun_unittest.minimumHoursSinceInstall", 10);
+ Services.prefs.setIntPref(kPagePrefRoot + "test_LaterRun_unittest.minimumSessionCount", 3);
+ let pages = LaterRun.readPages();
+ // We have to filter the pages because it's possible Firefox ships with other URLs
+ // that get triggered in this test.
+ pages = pages.filter(page => page.pref == kPagePrefRoot + "test_LaterRun_unittest.");
+ Assert.equal(pages.length, 0, "URL with non-https scheme should get ignored");
+ clearAllPagePrefs();
+});
+
+add_task(function* test_dynamic_pref_getter_setter() {
+ delete LaterRun._sessionCount;
+ Services.prefs.setIntPref(kSessionCountPref, 0);
+ Assert.equal(LaterRun.sessionCount, 0, "Should start at 0");
+
+ LaterRun.sessionCount++;
+ Assert.equal(LaterRun.sessionCount, 1, "Should increment.");
+ Assert.equal(Services.prefs.getIntPref(kSessionCountPref), 1, "Should update pref");
+});
+
+function clearAllPagePrefs() {
+ let allChangedPrefs = Services.prefs.getChildList(kPagePrefRoot);
+ for (let pref of allChangedPrefs) {
+ Services.prefs.clearUserPref(pref);
+ }
+}
+
diff --git a/browser/modules/test/xpcshell/test_SitePermissions.js b/browser/modules/test/xpcshell/test_SitePermissions.js
new file mode 100644
index 000000000..808d96599
--- /dev/null
+++ b/browser/modules/test/xpcshell/test_SitePermissions.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+Components.utils.import("resource:///modules/SitePermissions.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+add_task(function* testPermissionsListing() {
+ Assert.deepEqual(SitePermissions.listPermissions().sort(),
+ ["camera", "cookie", "desktop-notification", "geo", "image",
+ "indexedDB", "install", "microphone", "popup", "screen"],
+ "Correct list of all permissions");
+});
+
+add_task(function* testGetAllByURI() {
+ // check that it returns an empty array on an invalid URI
+ // like a file URI, which doesn't support site permissions
+ let wrongURI = Services.io.newURI("file:///example.js", null, null)
+ Assert.deepEqual(SitePermissions.getAllByURI(wrongURI), []);
+
+ let uri = Services.io.newURI("https://example.com", null, null)
+ Assert.deepEqual(SitePermissions.getAllByURI(uri), []);
+
+ SitePermissions.set(uri, "camera", SitePermissions.ALLOW);
+ Assert.deepEqual(SitePermissions.getAllByURI(uri), [
+ { id: "camera", state: SitePermissions.ALLOW }
+ ]);
+
+ SitePermissions.set(uri, "microphone", SitePermissions.SESSION);
+ SitePermissions.set(uri, "desktop-notification", SitePermissions.BLOCK);
+
+ Assert.deepEqual(SitePermissions.getAllByURI(uri), [
+ { id: "camera", state: SitePermissions.ALLOW },
+ { id: "microphone", state: SitePermissions.SESSION },
+ { id: "desktop-notification", state: SitePermissions.BLOCK }
+ ]);
+
+ SitePermissions.remove(uri, "microphone");
+ Assert.deepEqual(SitePermissions.getAllByURI(uri), [
+ { id: "camera", state: SitePermissions.ALLOW },
+ { id: "desktop-notification", state: SitePermissions.BLOCK }
+ ]);
+
+ SitePermissions.remove(uri, "camera");
+ SitePermissions.remove(uri, "desktop-notification");
+ Assert.deepEqual(SitePermissions.getAllByURI(uri), []);
+
+ // XXX Bug 1303108 - Control Center should only show non-default permissions
+ SitePermissions.set(uri, "addon", SitePermissions.BLOCK);
+ Assert.deepEqual(SitePermissions.getAllByURI(uri), []);
+ SitePermissions.remove(uri, "addon");
+});
+
+add_task(function* testGetPermissionDetailsByURI() {
+ // check that it returns an empty array on an invalid URI
+ // like a file URI, which doesn't support site permissions
+ let wrongURI = Services.io.newURI("file:///example.js", null, null)
+ Assert.deepEqual(SitePermissions.getPermissionDetailsByURI(wrongURI), []);
+
+ let uri = Services.io.newURI("https://example.com", null, null)
+
+ SitePermissions.set(uri, "camera", SitePermissions.ALLOW);
+ SitePermissions.set(uri, "cookie", SitePermissions.SESSION);
+ SitePermissions.set(uri, "popup", SitePermissions.BLOCK);
+
+ let permissions = SitePermissions.getPermissionDetailsByURI(uri);
+
+ let camera = permissions.find(({id}) => id === "camera");
+ Assert.deepEqual(camera, {
+ id: "camera",
+ label: "Use the Camera",
+ state: SitePermissions.ALLOW,
+ availableStates: [
+ { id: SitePermissions.UNKNOWN, label: "Always Ask" },
+ { id: SitePermissions.ALLOW, label: "Allow" },
+ { id: SitePermissions.BLOCK, label: "Block" },
+ ]
+ });
+
+ // check that removed permissions (State.UNKNOWN) are skipped
+ SitePermissions.remove(uri, "camera");
+ permissions = SitePermissions.getPermissionDetailsByURI(uri);
+
+ camera = permissions.find(({id}) => id === "camera");
+ Assert.equal(camera, undefined);
+
+ // check that different available state values are represented
+
+ let cookie = permissions.find(({id}) => id === "cookie");
+ Assert.deepEqual(cookie, {
+ id: "cookie",
+ label: "Set Cookies",
+ state: SitePermissions.SESSION,
+ availableStates: [
+ { id: SitePermissions.ALLOW, label: "Allow" },
+ { id: SitePermissions.SESSION, label: "Allow for Session" },
+ { id: SitePermissions.BLOCK, label: "Block" },
+ ]
+ });
+
+ let popup = permissions.find(({id}) => id === "popup");
+ Assert.deepEqual(popup, {
+ id: "popup",
+ label: "Open Pop-up Windows",
+ state: SitePermissions.BLOCK,
+ availableStates: [
+ { id: SitePermissions.ALLOW, label: "Allow" },
+ { id: SitePermissions.BLOCK, label: "Block" },
+ ]
+ });
+
+ SitePermissions.remove(uri, "cookie");
+ SitePermissions.remove(uri, "popup");
+});
diff --git a/browser/modules/test/xpcshell/xpcshell.ini b/browser/modules/test/xpcshell/xpcshell.ini
new file mode 100644
index 000000000..28df9c4ed
--- /dev/null
+++ b/browser/modules/test/xpcshell/xpcshell.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+head =
+tail =
+firefox-appdir = browser
+skip-if = toolkit == 'android'
+
+[test_AttributionCode.js]
+skip-if = os != 'win'
+[test_DirectoryLinksProvider.js]
+[test_SitePermissions.js]
+[test_LaterRun.js]
diff --git a/browser/modules/webrtcUI.jsm b/browser/modules/webrtcUI.jsm
new file mode 100644
index 000000000..b24135bfc
--- /dev/null
+++ b/browser/modules/webrtcUI.jsm
@@ -0,0 +1,963 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["webrtcUI"];
+
+const Cu = Components.utils;
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
+ "resource://gre/modules/AppConstants.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
+ "resource://gre/modules/PluralForm.jsm");
+
+this.webrtcUI = {
+ init: function () {
+ Services.obs.addObserver(maybeAddMenuIndicator, "browser-delayed-startup-finished", false);
+
+ let ppmm = Cc["@mozilla.org/parentprocessmessagemanager;1"]
+ .getService(Ci.nsIMessageBroadcaster);
+ ppmm.addMessageListener("webrtc:UpdatingIndicators", this);
+ ppmm.addMessageListener("webrtc:UpdateGlobalIndicators", this);
+ ppmm.addMessageListener("child-process-shutdown", this);
+
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"]
+ .getService(Ci.nsIMessageListenerManager);
+ mm.addMessageListener("rtcpeer:Request", this);
+ mm.addMessageListener("rtcpeer:CancelRequest", this);
+ mm.addMessageListener("webrtc:Request", this);
+ mm.addMessageListener("webrtc:CancelRequest", this);
+ mm.addMessageListener("webrtc:UpdateBrowserIndicators", this);
+ },
+
+ uninit: function () {
+ Services.obs.removeObserver(maybeAddMenuIndicator, "browser-delayed-startup-finished");
+
+ let ppmm = Cc["@mozilla.org/parentprocessmessagemanager;1"]
+ .getService(Ci.nsIMessageBroadcaster);
+ ppmm.removeMessageListener("webrtc:UpdatingIndicators", this);
+ ppmm.removeMessageListener("webrtc:UpdateGlobalIndicators", this);
+
+ let mm = Cc["@mozilla.org/globalmessagemanager;1"]
+ .getService(Ci.nsIMessageListenerManager);
+ mm.removeMessageListener("rtcpeer:Request", this);
+ mm.removeMessageListener("rtcpeer:CancelRequest", this);
+ mm.removeMessageListener("webrtc:Request", this);
+ mm.removeMessageListener("webrtc:CancelRequest", this);
+ mm.removeMessageListener("webrtc:UpdateBrowserIndicators", this);
+
+ if (gIndicatorWindow) {
+ gIndicatorWindow.close();
+ gIndicatorWindow = null;
+ }
+ },
+
+ processIndicators: new Map(),
+
+ get showGlobalIndicator() {
+ for (let [, indicators] of this.processIndicators) {
+ if (indicators.showGlobalIndicator)
+ return true;
+ }
+ return false;
+ },
+
+ get showCameraIndicator() {
+ for (let [, indicators] of this.processIndicators) {
+ if (indicators.showCameraIndicator)
+ return true;
+ }
+ return false;
+ },
+
+ get showMicrophoneIndicator() {
+ for (let [, indicators] of this.processIndicators) {
+ if (indicators.showMicrophoneIndicator)
+ return true;
+ }
+ return false;
+ },
+
+ get showScreenSharingIndicator() {
+ let list = [""];
+ for (let [, indicators] of this.processIndicators) {
+ if (indicators.showScreenSharingIndicator)
+ list.push(indicators.showScreenSharingIndicator);
+ }
+
+ let precedence =
+ ["Screen", "Window", "Application", "Browser", ""];
+
+ list.sort((a, b) => { return precedence.indexOf(a) -
+ precedence.indexOf(b); });
+
+ return list[0];
+ },
+
+ _streams: [],
+ // The boolean parameters indicate which streams should be included in the result.
+ getActiveStreams: function(aCamera, aMicrophone, aScreen) {
+ return webrtcUI._streams.filter(aStream => {
+ let state = aStream.state;
+ return aCamera && state.camera ||
+ aMicrophone && state.microphone ||
+ aScreen && state.screen;
+ }).map(aStream => {
+ let state = aStream.state;
+ let types = {camera: state.camera, microphone: state.microphone,
+ screen: state.screen};
+ let browser = aStream.browser;
+ let browserWindow = browser.ownerGlobal;
+ let tab = browserWindow.gBrowser &&
+ browserWindow.gBrowser.getTabForBrowser(browser);
+ return {uri: state.documentURI, tab: tab, browser: browser, types: types};
+ });
+ },
+
+ swapBrowserForNotification: function(aOldBrowser, aNewBrowser) {
+ for (let stream of this._streams) {
+ if (stream.browser == aOldBrowser)
+ stream.browser = aNewBrowser;
+ }
+ },
+
+ forgetStreamsFromBrowser: function(aBrowser) {
+ this._streams = this._streams.filter(stream => stream.browser != aBrowser);
+ },
+
+ showSharingDoorhanger: function(aActiveStream, aType) {
+ let browserWindow = aActiveStream.browser.ownerGlobal;
+ if (aActiveStream.tab) {
+ browserWindow.gBrowser.selectedTab = aActiveStream.tab;
+ } else {
+ aActiveStream.browser.focus();
+ }
+ browserWindow.focus();
+ let identityBox = browserWindow.document.getElementById("identity-box");
+ if (AppConstants.platform == "macosx" && !Services.focus.activeWindow) {
+ browserWindow.addEventListener("activate", function onActivate() {
+ browserWindow.removeEventListener("activate", onActivate);
+ Services.tm.mainThread.dispatch(function() {
+ identityBox.click();
+ }, Ci.nsIThread.DISPATCH_NORMAL);
+ });
+ Cc["@mozilla.org/widget/macdocksupport;1"].getService(Ci.nsIMacDockSupport)
+ .activateApplication(true);
+ return;
+ }
+ identityBox.click();
+ },
+
+ updateMainActionLabel: function(aMenuList) {
+ let type = aMenuList.selectedItem.getAttribute("devicetype");
+ let document = aMenuList.ownerDocument;
+ document.getElementById("webRTC-all-windows-shared").hidden = type != "Screen";
+
+ // If we are also requesting audio in addition to screen sharing,
+ // always use a generic label.
+ if (!document.getElementById("webRTC-selectMicrophone").hidden)
+ type = "";
+
+ let bundle = document.defaultView.gNavigatorBundle;
+ let stringId = "getUserMedia.share" + (type || "SelectedItems") + ".label";
+ let popupnotification = aMenuList.parentNode.parentNode;
+ popupnotification.setAttribute("buttonlabel", bundle.getString(stringId));
+ },
+
+ receiveMessage: function(aMessage) {
+ switch (aMessage.name) {
+
+ // Add-ons can override stock permission behavior by doing:
+ //
+ // var stockReceiveMessage = webrtcUI.receiveMessage;
+ //
+ // webrtcUI.receiveMessage = function(aMessage) {
+ // switch (aMessage.name) {
+ // case "rtcpeer:Request": {
+ // // new code.
+ // break;
+ // ...
+ // default:
+ // return stockReceiveMessage.call(this, aMessage);
+ //
+ // Intercepting gUM and peerConnection requests should let an add-on
+ // limit PeerConnection activity with automatic rules and/or prompts
+ // in a sensible manner that avoids double-prompting in typical
+ // gUM+PeerConnection scenarios. For example:
+ //
+ // State Sample Action
+ // --------------------------------------------------------------
+ // No IP leaked yet + No gUM granted Warn user
+ // No IP leaked yet + gUM granted Avoid extra dialog
+ // No IP leaked yet + gUM request pending. Delay until gUM grant
+ // IP already leaked Too late to warn
+
+ case "rtcpeer:Request": {
+ // Always allow. This code-point exists for add-ons to override.
+ let { callID, windowID } = aMessage.data;
+ // Also available: isSecure, innerWindowID. For contentWindow:
+ //
+ // let contentWindow = Services.wm.getOuterWindowWithId(windowID);
+
+ let mm = aMessage.target.messageManager;
+ mm.sendAsyncMessage("rtcpeer:Allow",
+ { callID: callID, windowID: windowID });
+ break;
+ }
+ case "rtcpeer:CancelRequest":
+ // No data to release. This code-point exists for add-ons to override.
+ break;
+ case "webrtc:Request":
+ prompt(aMessage.target, aMessage.data);
+ break;
+ case "webrtc:CancelRequest":
+ removePrompt(aMessage.target, aMessage.data);
+ break;
+ case "webrtc:UpdatingIndicators":
+ webrtcUI._streams = [];
+ break;
+ case "webrtc:UpdateGlobalIndicators":
+ updateIndicators(aMessage.data, aMessage.target);
+ break;
+ case "webrtc:UpdateBrowserIndicators":
+ let id = aMessage.data.windowId;
+ let index;
+ for (index = 0; index < webrtcUI._streams.length; ++index) {
+ if (webrtcUI._streams[index].state.windowId == id)
+ break;
+ }
+ // If there's no documentURI, the update is actually a removal of the
+ // stream, triggered by the recording-window-ended notification.
+ if (!aMessage.data.documentURI && index < webrtcUI._streams.length)
+ webrtcUI._streams.splice(index, 1);
+ else
+ webrtcUI._streams[index] = {browser: aMessage.target, state: aMessage.data};
+ let tabbrowser = aMessage.target.ownerGlobal.gBrowser;
+ if (tabbrowser)
+ tabbrowser.setBrowserSharing(aMessage.target, aMessage.data);
+ break;
+ case "child-process-shutdown":
+ webrtcUI.processIndicators.delete(aMessage.target);
+ updateIndicators(null, null);
+ break;
+ }
+ }
+};
+
+function getBrowserForWindow(aContentWindow) {
+ return aContentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShell)
+ .chromeEventHandler;
+}
+
+function denyRequest(aBrowser, aRequest) {
+ aBrowser.messageManager.sendAsyncMessage("webrtc:Deny",
+ {callID: aRequest.callID,
+ windowID: aRequest.windowID});
+}
+
+function getHost(uri, href) {
+ let host;
+ try {
+ if (!uri) {
+ uri = Services.io.newURI(href, null, null);
+ }
+ host = uri.host;
+ } catch (ex) {}
+ if (!host) {
+ if (uri && uri.scheme.toLowerCase() == "about") {
+ // For about URIs, just use the full spec, without any #hash parts.
+ host = uri.specIgnoringRef;
+ } else {
+ // This is unfortunate, but we should display *something*...
+ const kBundleURI = "chrome://browser/locale/browser.properties";
+ let bundle = Services.strings.createBundle(kBundleURI);
+ host = bundle.GetStringFromName("getUserMedia.sharingMenuUnknownHost");
+ }
+ }
+ return host;
+}
+
+function prompt(aBrowser, aRequest) {
+ let {audioDevices: audioDevices, videoDevices: videoDevices,
+ sharingScreen: sharingScreen, sharingAudio: sharingAudio,
+ requestTypes: requestTypes} = aRequest;
+ let uri = Services.io.newURI(aRequest.documentURI, null, null);
+ let host = getHost(uri);
+ let chromeDoc = aBrowser.ownerDocument;
+ let chromeWin = chromeDoc.defaultView;
+ let stringBundle = chromeWin.gNavigatorBundle;
+ let stringId = "getUserMedia.share" + requestTypes.join("And") + ".message";
+ let message = stringBundle.getFormattedString(stringId, [host]);
+
+ let mainLabel;
+ if (sharingScreen || sharingAudio) {
+ mainLabel = stringBundle.getString("getUserMedia.shareSelectedItems.label");
+ } else {
+ let string = stringBundle.getString("getUserMedia.shareSelectedDevices.label");
+ mainLabel = PluralForm.get(requestTypes.length, string);
+ }
+
+ let notification; // Used by action callbacks.
+ let mainAction = {
+ label: mainLabel,
+ accessKey: stringBundle.getString("getUserMedia.shareSelectedDevices.accesskey"),
+ // The real callback will be set during the "showing" event. The
+ // empty function here is so that PopupNotifications.show doesn't
+ // reject the action.
+ callback: function() {}
+ };
+
+ let secondaryActions = [
+ {
+ label: stringBundle.getString("getUserMedia.denyRequest.label"),
+ accessKey: stringBundle.getString("getUserMedia.denyRequest.accesskey"),
+ callback: function () {
+ denyRequest(notification.browser, aRequest);
+ }
+ }
+ ];
+ // Bug 1037438: implement 'never' for screen sharing.
+ if (!sharingScreen && !sharingAudio) {
+ secondaryActions.push({
+ label: stringBundle.getString("getUserMedia.never.label"),
+ accessKey: stringBundle.getString("getUserMedia.never.accesskey"),
+ callback: function () {
+ denyRequest(notification.browser, aRequest);
+ // Let someone save "Never" for http sites so that they can be stopped from
+ // bothering you with doorhangers.
+ let perms = Services.perms;
+ if (audioDevices.length)
+ perms.add(uri, "microphone", perms.DENY_ACTION);
+ if (videoDevices.length)
+ perms.add(uri, "camera", perms.DENY_ACTION);
+ }
+ });
+ }
+
+ if (aRequest.secure && !sharingScreen && !sharingAudio) {
+ // Don't show the 'Always' action if the connection isn't secure, or for
+ // screen/audio sharing (because we can't guess which window the user wants
+ // to share without prompting).
+ secondaryActions.unshift({
+ label: stringBundle.getString("getUserMedia.always.label"),
+ accessKey: stringBundle.getString("getUserMedia.always.accesskey"),
+ callback: function (aState) {
+ mainAction.callback(aState, true);
+ }
+ });
+ }
+
+ let options = {
+ eventCallback: function(aTopic, aNewBrowser) {
+ if (aTopic == "swapping")
+ return true;
+
+ let chromeDoc = this.browser.ownerDocument;
+
+ // Clean-up video streams of screensharing previews.
+ if ((aTopic == "dismissed" || aTopic == "removed") &&
+ requestTypes.includes("Screen")) {
+ let video = chromeDoc.getElementById("webRTC-previewVideo");
+ video.deviceId = undefined;
+ if (video.stream) {
+ video.stream.getTracks().forEach(t => t.stop());
+ video.stream = null;
+ video.src = null;
+ chromeDoc.getElementById("webRTC-preview").hidden = true;
+ }
+ let menupopup = chromeDoc.getElementById("webRTC-selectWindow-menupopup");
+ if (menupopup._commandEventListener) {
+ menupopup.removeEventListener("command", menupopup._commandEventListener);
+ menupopup._commandEventListener = null;
+ }
+ }
+
+ if (aTopic != "showing")
+ return false;
+
+ // DENY_ACTION is handled immediately by MediaManager, but handling
+ // of ALLOW_ACTION is delayed until the popupshowing event
+ // to avoid granting permissions automatically to background tabs.
+ if (aRequest.secure) {
+ let perms = Services.perms;
+
+ let micPerm = perms.testExactPermission(uri, "microphone");
+ if (micPerm == perms.PROMPT_ACTION)
+ micPerm = perms.UNKNOWN_ACTION;
+
+ let camPerm = perms.testExactPermission(uri, "camera");
+
+ let mediaManagerPerm =
+ perms.testExactPermission(uri, "MediaManagerVideo");
+ if (mediaManagerPerm) {
+ perms.remove(uri, "MediaManagerVideo");
+ }
+
+ if (camPerm == perms.PROMPT_ACTION)
+ camPerm = perms.UNKNOWN_ACTION;
+
+ // Screen sharing shouldn't follow the camera permissions.
+ if (videoDevices.length && sharingScreen)
+ camPerm = perms.UNKNOWN_ACTION;
+
+ // We don't check that permissions are set to ALLOW_ACTION in this
+ // test; only that they are set. This is because if audio is allowed
+ // and video is denied persistently, we don't want to show the prompt,
+ // and will grant audio access immediately.
+ if ((!audioDevices.length || micPerm) && (!videoDevices.length || camPerm)) {
+ // All permissions we were about to request are already persistently set.
+ let allowedDevices = [];
+ if (videoDevices.length && camPerm == perms.ALLOW_ACTION) {
+ allowedDevices.push(videoDevices[0].deviceIndex);
+ let perms = Services.perms;
+ perms.add(uri, "MediaManagerVideo", perms.ALLOW_ACTION,
+ perms.EXPIRE_SESSION);
+ }
+ if (audioDevices.length && micPerm == perms.ALLOW_ACTION)
+ allowedDevices.push(audioDevices[0].deviceIndex);
+
+ // Remember on which URIs we found persistent permissions so that we
+ // can remove them if the user clicks 'Stop Sharing'. There's no
+ // other way for the stop sharing code to know the hostnames of frames
+ // using devices until bug 1066082 is fixed.
+ let browser = this.browser;
+ browser._devicePermissionURIs = browser._devicePermissionURIs || [];
+ browser._devicePermissionURIs.push(uri);
+
+ let mm = browser.messageManager;
+ mm.sendAsyncMessage("webrtc:Allow", {callID: aRequest.callID,
+ windowID: aRequest.windowID,
+ devices: allowedDevices});
+ this.remove();
+ return true;
+ }
+ }
+
+ function listDevices(menupopup, devices) {
+ while (menupopup.lastChild)
+ menupopup.removeChild(menupopup.lastChild);
+
+ for (let device of devices)
+ addDeviceToList(menupopup, device.name, device.deviceIndex);
+ }
+
+ function listScreenShareDevices(menupopup, devices) {
+ while (menupopup.lastChild)
+ menupopup.removeChild(menupopup.lastChild);
+
+ let type = devices[0].mediaSource;
+ let typeName = type.charAt(0).toUpperCase() + type.substr(1);
+
+ let label = chromeDoc.getElementById("webRTC-selectWindow-label");
+ let stringId = "getUserMedia.select" + typeName;
+ label.setAttribute("value",
+ stringBundle.getString(stringId + ".label"));
+ label.setAttribute("accesskey",
+ stringBundle.getString(stringId + ".accesskey"));
+
+ // "No <type>" is the default because we can't pick a
+ // 'default' window to share.
+ addDeviceToList(menupopup,
+ stringBundle.getString("getUserMedia.no" + typeName + ".label"),
+ "-1");
+ menupopup.appendChild(chromeDoc.createElement("menuseparator"));
+
+ // Build the list of 'devices'.
+ let monitorIndex = 1;
+ for (let i = 0; i < devices.length; ++i) {
+ let device = devices[i];
+
+ let name;
+ // Building screen list from available screens.
+ if (type == "screen") {
+ if (device.name == "Primary Monitor") {
+ name = stringBundle.getString("getUserMedia.shareEntireScreen.label");
+ } else {
+ name = stringBundle.getFormattedString("getUserMedia.shareMonitor.label",
+ [monitorIndex]);
+ ++monitorIndex;
+ }
+ }
+ else {
+ name = device.name;
+ if (type == "application") {
+ // The application names returned by the platform are of the form:
+ // <window count>\x1e<application name>
+ let sepIndex = name.indexOf("\x1e");
+ let count = name.slice(0, sepIndex);
+ let stringId = "getUserMedia.shareApplicationWindowCount.label";
+ name = PluralForm.get(parseInt(count), stringBundle.getString(stringId))
+ .replace("#1", name.slice(sepIndex + 1))
+ .replace("#2", count);
+ }
+ }
+ let item = addDeviceToList(menupopup, name, i, typeName);
+ item.deviceId = device.id;
+ if (device.scary)
+ item.scary = true;
+ }
+
+ // Always re-select the "No <type>" item.
+ chromeDoc.getElementById("webRTC-selectWindow-menulist").removeAttribute("value");
+ chromeDoc.getElementById("webRTC-all-windows-shared").hidden = true;
+ menupopup._commandEventListener = event => {
+ let video = chromeDoc.getElementById("webRTC-previewVideo");
+ if (video.stream) {
+ video.stream.getTracks().forEach(t => t.stop());
+ video.stream = null;
+ }
+
+ let deviceId = event.target.deviceId;
+ if (deviceId == undefined) {
+ chromeDoc.getElementById("webRTC-preview").hidden = true;
+ video.src = null;
+ return;
+ }
+
+ let scary = event.target.scary;
+ let warning = chromeDoc.getElementById("webRTC-previewWarning");
+ warning.hidden = !scary;
+ let chromeWin = chromeDoc.defaultView;
+ if (scary) {
+ warning.hidden = false;
+ let string;
+ let bundle = chromeWin.gNavigatorBundle;
+
+ let learnMoreText =
+ bundle.getString("getUserMedia.shareScreen.learnMoreLabel");
+ let baseURL =
+ Services.urlFormatter.formatURLPref("app.support.baseURL");
+ let learnMore =
+ "<label class='text-link' href='" + baseURL + "screenshare-safety'>" +
+ learnMoreText + "</label>";
+
+ if (type == "screen") {
+ string = bundle.getFormattedString("getUserMedia.shareScreenWarning.message",
+ [learnMore]);
+ }
+ else {
+ let brand =
+ chromeDoc.getElementById("bundle_brand").getString("brandShortName");
+ string = bundle.getFormattedString("getUserMedia.shareFirefoxWarning.message",
+ [brand, learnMore]);
+ }
+ warning.innerHTML = string;
+ }
+
+ let perms = Services.perms;
+ let chromeUri = Services.io.newURI(chromeDoc.documentURI, null, null);
+ perms.add(chromeUri, "MediaManagerVideo", perms.ALLOW_ACTION,
+ perms.EXPIRE_SESSION);
+
+ video.deviceId = deviceId;
+ let constraints = { video: { mediaSource: type, deviceId: {exact: deviceId } } };
+ chromeWin.navigator.mediaDevices.getUserMedia(constraints).then(stream => {
+ if (video.deviceId != deviceId) {
+ // The user has selected a different device or closed the panel
+ // before getUserMedia finished.
+ stream.getTracks().forEach(t => t.stop());
+ return;
+ }
+ video.src = chromeWin.URL.createObjectURL(stream);
+ video.stream = stream;
+ chromeDoc.getElementById("webRTC-preview").hidden = false;
+ video.onloadedmetadata = function(e) {
+ video.play();
+ };
+ });
+ };
+ menupopup.addEventListener("command", menupopup._commandEventListener);
+ }
+
+ function addDeviceToList(menupopup, deviceName, deviceIndex, type) {
+ let menuitem = chromeDoc.createElement("menuitem");
+ menuitem.setAttribute("value", deviceIndex);
+ menuitem.setAttribute("label", deviceName);
+ menuitem.setAttribute("tooltiptext", deviceName);
+ if (type)
+ menuitem.setAttribute("devicetype", type);
+ menupopup.appendChild(menuitem);
+ return menuitem;
+ }
+
+ chromeDoc.getElementById("webRTC-selectCamera").hidden = !videoDevices.length || sharingScreen;
+ chromeDoc.getElementById("webRTC-selectWindowOrScreen").hidden = !sharingScreen || !videoDevices.length;
+ chromeDoc.getElementById("webRTC-selectMicrophone").hidden = !audioDevices.length || sharingAudio;
+
+ let camMenupopup = chromeDoc.getElementById("webRTC-selectCamera-menupopup");
+ let windowMenupopup = chromeDoc.getElementById("webRTC-selectWindow-menupopup");
+ let micMenupopup = chromeDoc.getElementById("webRTC-selectMicrophone-menupopup");
+ if (sharingScreen)
+ listScreenShareDevices(windowMenupopup, videoDevices);
+ else
+ listDevices(camMenupopup, videoDevices);
+
+ if (!sharingAudio)
+ listDevices(micMenupopup, audioDevices);
+
+ this.mainAction.callback = function(aState, aRemember) {
+ let allowedDevices = [];
+ let perms = Services.perms;
+ if (videoDevices.length) {
+ let listId = "webRTC-select" + (sharingScreen ? "Window" : "Camera") + "-menulist";
+ let videoDeviceIndex = chromeDoc.getElementById(listId).value;
+ let allowCamera = videoDeviceIndex != "-1";
+ if (allowCamera) {
+ allowedDevices.push(videoDeviceIndex);
+ // Session permission will be removed after use
+ // (it's really one-shot, not for the entire session)
+ perms.add(uri, "MediaManagerVideo", perms.ALLOW_ACTION,
+ perms.EXPIRE_SESSION);
+ }
+ if (aRemember) {
+ perms.add(uri, "camera",
+ allowCamera ? perms.ALLOW_ACTION : perms.DENY_ACTION);
+ }
+ }
+ if (audioDevices.length) {
+ if (!sharingAudio) {
+ let audioDeviceIndex = chromeDoc.getElementById("webRTC-selectMicrophone-menulist").value;
+ let allowMic = audioDeviceIndex != "-1";
+ if (allowMic)
+ allowedDevices.push(audioDeviceIndex);
+ if (aRemember) {
+ perms.add(uri, "microphone",
+ allowMic ? perms.ALLOW_ACTION : perms.DENY_ACTION);
+ }
+ } else {
+ // Only one device possible for audio capture.
+ allowedDevices.push(0);
+ }
+ }
+
+ if (!allowedDevices.length) {
+ denyRequest(notification.browser, aRequest);
+ return;
+ }
+
+ if (aRemember) {
+ // Remember on which URIs we set persistent permissions so that we
+ // can remove them if the user clicks 'Stop Sharing'.
+ aBrowser._devicePermissionURIs = aBrowser._devicePermissionURIs || [];
+ aBrowser._devicePermissionURIs.push(uri);
+ }
+
+ let mm = notification.browser.messageManager;
+ mm.sendAsyncMessage("webrtc:Allow", {callID: aRequest.callID,
+ windowID: aRequest.windowID,
+ devices: allowedDevices});
+ };
+ return false;
+ }
+ };
+
+ let iconType = "Devices";
+ if (requestTypes.length == 1 && (requestTypes[0] == "Microphone" ||
+ requestTypes[0] == "AudioCapture"))
+ iconType = "Microphone";
+ if (requestTypes.includes("Screen"))
+ iconType = "Screen";
+ let anchorId = "webRTC-share" + iconType + "-notification-icon";
+
+ let iconClass = iconType.toLowerCase();
+ if (iconClass == "devices")
+ iconClass = "camera";
+ options.popupIconClass = iconClass + "-icon";
+
+ notification =
+ chromeWin.PopupNotifications.show(aBrowser, "webRTC-shareDevices", message,
+ anchorId, mainAction, secondaryActions,
+ options);
+ notification.callID = aRequest.callID;
+}
+
+function removePrompt(aBrowser, aCallId) {
+ let chromeWin = aBrowser.ownerGlobal;
+ let notification =
+ chromeWin.PopupNotifications.getNotification("webRTC-shareDevices", aBrowser);
+ if (notification && notification.callID == aCallId)
+ notification.remove();
+}
+
+function getGlobalIndicator() {
+ if (AppConstants.platform != "macosx") {
+ const INDICATOR_CHROME_URI = "chrome://browser/content/webrtcIndicator.xul";
+ const features = "chrome,dialog=yes,titlebar=no,popup=yes";
+
+ return Services.ww.openWindow(null, INDICATOR_CHROME_URI, "_blank", features, []);
+ }
+
+ let indicator = {
+ _camera: null,
+ _microphone: null,
+ _screen: null,
+
+ _hiddenDoc: Cc["@mozilla.org/appshell/appShellService;1"]
+ .getService(Ci.nsIAppShellService)
+ .hiddenDOMWindow.document,
+ _statusBar: Cc["@mozilla.org/widget/macsystemstatusbar;1"]
+ .getService(Ci.nsISystemStatusBar),
+
+ _command: function(aEvent) {
+ let type = this.getAttribute("type");
+ if (type == "Camera" || type == "Microphone")
+ type = "Devices";
+ else if (type == "Window" || type == "Application" || type == "Browser")
+ type = "Screen";
+ webrtcUI.showSharingDoorhanger(aEvent.target.stream, type);
+ },
+
+ _popupShowing: function(aEvent) {
+ let type = this.getAttribute("type");
+ let activeStreams;
+ if (type == "Camera") {
+ activeStreams = webrtcUI.getActiveStreams(true, false, false);
+ }
+ else if (type == "Microphone") {
+ activeStreams = webrtcUI.getActiveStreams(false, true, false);
+ }
+ else if (type == "Screen") {
+ activeStreams = webrtcUI.getActiveStreams(false, false, true);
+ type = webrtcUI.showScreenSharingIndicator;
+ }
+
+ let bundle =
+ Services.strings.createBundle("chrome://browser/locale/webrtcIndicator.properties");
+
+ if (activeStreams.length == 1) {
+ let stream = activeStreams[0];
+
+ let menuitem = this.ownerDocument.createElement("menuitem");
+ let labelId = "webrtcIndicator.sharing" + type + "With.menuitem";
+ let label = stream.browser.contentTitle || stream.uri;
+ menuitem.setAttribute("label", bundle.formatStringFromName(labelId, [label], 1));
+ menuitem.setAttribute("disabled", "true");
+ this.appendChild(menuitem);
+
+ menuitem = this.ownerDocument.createElement("menuitem");
+ menuitem.setAttribute("label",
+ bundle.GetStringFromName("webrtcIndicator.controlSharing.menuitem"));
+ menuitem.setAttribute("type", type);
+ menuitem.stream = stream;
+ menuitem.addEventListener("command", indicator._command);
+
+ this.appendChild(menuitem);
+ return true;
+ }
+
+ // We show a different menu when there are several active streams.
+ let menuitem = this.ownerDocument.createElement("menuitem");
+ let labelId = "webrtcIndicator.sharing" + type + "WithNTabs.menuitem";
+ let count = activeStreams.length;
+ let label = PluralForm.get(count, bundle.GetStringFromName(labelId)).replace("#1", count);
+ menuitem.setAttribute("label", label);
+ menuitem.setAttribute("disabled", "true");
+ this.appendChild(menuitem);
+
+ for (let stream of activeStreams) {
+ let item = this.ownerDocument.createElement("menuitem");
+ let labelId = "webrtcIndicator.controlSharingOn.menuitem";
+ let label = stream.browser.contentTitle || stream.uri;
+ item.setAttribute("label", bundle.formatStringFromName(labelId, [label], 1));
+ item.setAttribute("type", type);
+ item.stream = stream;
+ item.addEventListener("command", indicator._command);
+ this.appendChild(item);
+ }
+
+ return true;
+ },
+
+ _popupHiding: function(aEvent) {
+ while (this.firstChild)
+ this.firstChild.remove();
+ },
+
+ _setIndicatorState: function(aName, aState) {
+ let field = "_" + aName.toLowerCase();
+ if (aState && !this[field]) {
+ let menu = this._hiddenDoc.createElement("menu");
+ menu.setAttribute("id", "webRTC-sharing" + aName + "-menu");
+
+ // The CSS will only be applied if the menu is actually inserted in the DOM.
+ this._hiddenDoc.documentElement.appendChild(menu);
+
+ this._statusBar.addItem(menu);
+
+ let menupopup = this._hiddenDoc.createElement("menupopup");
+ menupopup.setAttribute("type", aName);
+ menupopup.addEventListener("popupshowing", this._popupShowing);
+ menupopup.addEventListener("popuphiding", this._popupHiding);
+ menupopup.addEventListener("command", this._command);
+ menu.appendChild(menupopup);
+
+ this[field] = menu;
+ }
+ else if (this[field] && !aState) {
+ this._statusBar.removeItem(this[field]);
+ this[field].remove();
+ this[field] = null
+ }
+ },
+ updateIndicatorState: function() {
+ this._setIndicatorState("Camera", webrtcUI.showCameraIndicator);
+ this._setIndicatorState("Microphone", webrtcUI.showMicrophoneIndicator);
+ this._setIndicatorState("Screen", webrtcUI.showScreenSharingIndicator);
+ },
+ close: function() {
+ this._setIndicatorState("Camera", false);
+ this._setIndicatorState("Microphone", false);
+ this._setIndicatorState("Screen", false);
+ }
+ };
+
+ indicator.updateIndicatorState();
+ return indicator;
+}
+
+function onTabSharingMenuPopupShowing(e) {
+ let streams = webrtcUI.getActiveStreams(true, true, true);
+ for (let streamInfo of streams) {
+ let stringName = "getUserMedia.sharingMenu";
+ let types = streamInfo.types;
+ if (types.camera)
+ stringName += "Camera";
+ if (types.microphone)
+ stringName += "Microphone";
+ if (types.screen)
+ stringName += types.screen;
+
+ let doc = e.target.ownerDocument;
+ let bundle = doc.defaultView.gNavigatorBundle;
+
+ let origin = getHost(null, streamInfo.uri);
+ let menuitem = doc.createElement("menuitem");
+ menuitem.setAttribute("label", bundle.getFormattedString(stringName, [origin]));
+ menuitem.stream = streamInfo;
+
+ // We can only open 1 doorhanger at a time. Guessing that users would be
+ // most eager to control screen/window/app sharing, and only then
+ // camera/microphone sharing, in that (decreasing) order of priority.
+ let doorhangerType;
+ if ((/Screen|Window|Application/).test(stringName)) {
+ doorhangerType = "Screen";
+ } else {
+ doorhangerType = "Devices";
+ }
+ menuitem.setAttribute("doorhangertype", doorhangerType);
+ menuitem.addEventListener("command", onTabSharingMenuPopupCommand);
+ e.target.appendChild(menuitem);
+ }
+}
+
+function onTabSharingMenuPopupHiding(e) {
+ while (this.lastChild)
+ this.lastChild.remove();
+}
+
+function onTabSharingMenuPopupCommand(e) {
+ let type = e.target.getAttribute("doorhangertype");
+ webrtcUI.showSharingDoorhanger(e.target.stream, type);
+}
+
+function showOrCreateMenuForWindow(aWindow) {
+ let document = aWindow.document;
+ let menu = document.getElementById("tabSharingMenu");
+ if (!menu) {
+ let stringBundle = aWindow.gNavigatorBundle;
+ menu = document.createElement("menu");
+ menu.id = "tabSharingMenu";
+ let labelStringId = "getUserMedia.sharingMenu.label";
+ menu.setAttribute("label", stringBundle.getString(labelStringId));
+
+ let container, insertionPoint;
+ if (AppConstants.platform == "macosx") {
+ container = document.getElementById("windowPopup");
+ insertionPoint = document.getElementById("sep-window-list");
+ let separator = document.createElement("menuseparator");
+ separator.id = "tabSharingSeparator";
+ container.insertBefore(separator, insertionPoint);
+ } else {
+ let accesskeyStringId = "getUserMedia.sharingMenu.accesskey";
+ menu.setAttribute("accesskey", stringBundle.getString(accesskeyStringId));
+ container = document.getElementById("main-menubar");
+ insertionPoint = document.getElementById("helpMenu");
+ }
+ let popup = document.createElement("menupopup");
+ popup.id = "tabSharingMenuPopup";
+ popup.addEventListener("popupshowing", onTabSharingMenuPopupShowing);
+ popup.addEventListener("popuphiding", onTabSharingMenuPopupHiding);
+ menu.appendChild(popup);
+ container.insertBefore(menu, insertionPoint);
+ } else {
+ menu.hidden = false;
+ if (AppConstants.platform == "macosx") {
+ document.getElementById("tabSharingSeparator").hidden = false;
+ }
+ }
+}
+
+function maybeAddMenuIndicator(window) {
+ if (webrtcUI.showGlobalIndicator) {
+ showOrCreateMenuForWindow(window);
+ }
+}
+
+var gIndicatorWindow = null;
+
+function updateIndicators(data, target) {
+ if (data) {
+ // the global indicators specific to this process
+ let indicators;
+ if (webrtcUI.processIndicators.has(target)) {
+ indicators = webrtcUI.processIndicators.get(target);
+ } else {
+ indicators = {};
+ webrtcUI.processIndicators.set(target, indicators);
+ }
+
+ indicators.showGlobalIndicator = data.showGlobalIndicator;
+ indicators.showCameraIndicator = data.showCameraIndicator;
+ indicators.showMicrophoneIndicator = data.showMicrophoneIndicator;
+ indicators.showScreenSharingIndicator = data.showScreenSharingIndicator;
+ }
+
+ let browserWindowEnum = Services.wm.getEnumerator("navigator:browser");
+ while (browserWindowEnum.hasMoreElements()) {
+ let chromeWin = browserWindowEnum.getNext();
+ if (webrtcUI.showGlobalIndicator) {
+ showOrCreateMenuForWindow(chromeWin);
+ } else {
+ let doc = chromeWin.document;
+ let existingMenu = doc.getElementById("tabSharingMenu");
+ if (existingMenu) {
+ existingMenu.hidden = true;
+ }
+ if (AppConstants.platform == "macosx") {
+ let separator = doc.getElementById("tabSharingSeparator");
+ if (separator) {
+ separator.hidden = true;
+ }
+ }
+ }
+ }
+
+ if (webrtcUI.showGlobalIndicator) {
+ if (!gIndicatorWindow)
+ gIndicatorWindow = getGlobalIndicator();
+ else
+ gIndicatorWindow.updateIndicatorState();
+ } else if (gIndicatorWindow) {
+ gIndicatorWindow.close();
+ gIndicatorWindow = null;
+ }
+}