diff options
Diffstat (limited to 'browser/modules')
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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + + 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; + } +} |