From 4492b5f8e774bf3b4f21e4e468fc052cbcbb468a Mon Sep 17 00:00:00 2001 From: Thomas Groman Date: Mon, 16 Dec 2019 19:48:42 -0800 Subject: initial commit --- modules/AboutHomeUtils.jsm | 67 +++ modules/AutoCompletePopup.jsm | 293 +++++++++++ modules/BrowserNewTabPreloader.jsm | 436 +++++++++++++++ modules/CharsetMenu.jsm | 160 ++++++ modules/FormSubmitObserver.jsm | 235 +++++++++ modules/FormValidationHandler.jsm | 157 ++++++ modules/NetworkPrioritizer.jsm | 179 +++++++ modules/PageMenu.jsm | 238 +++++++++ modules/PopupNotifications.jsm | 994 +++++++++++++++++++++++++++++++++++ modules/QuotaManager.jsm | 51 ++ modules/RecentWindow.jsm | 68 +++ modules/SharedFrame.jsm | 221 ++++++++ modules/Windows8WindowFrameColor.jsm | 53 ++ modules/WindowsJumpLists.jsm | 581 ++++++++++++++++++++ modules/WindowsPreviewPerTab.jsm | 861 ++++++++++++++++++++++++++++++ modules/moz.build | 42 ++ modules/offlineAppCache.jsm | 20 + modules/openLocationLastURL.jsm | 85 +++ modules/webrtcUI.jsm | 292 ++++++++++ 19 files changed, 5033 insertions(+) create mode 100644 modules/AboutHomeUtils.jsm create mode 100644 modules/AutoCompletePopup.jsm create mode 100644 modules/BrowserNewTabPreloader.jsm create mode 100644 modules/CharsetMenu.jsm create mode 100644 modules/FormSubmitObserver.jsm create mode 100644 modules/FormValidationHandler.jsm create mode 100644 modules/NetworkPrioritizer.jsm create mode 100644 modules/PageMenu.jsm create mode 100644 modules/PopupNotifications.jsm create mode 100644 modules/QuotaManager.jsm create mode 100644 modules/RecentWindow.jsm create mode 100644 modules/SharedFrame.jsm create mode 100644 modules/Windows8WindowFrameColor.jsm create mode 100644 modules/WindowsJumpLists.jsm create mode 100644 modules/WindowsPreviewPerTab.jsm create mode 100644 modules/moz.build create mode 100644 modules/offlineAppCache.jsm create mode 100644 modules/openLocationLastURL.jsm create mode 100644 modules/webrtcUI.jsm (limited to 'modules') diff --git a/modules/AboutHomeUtils.jsm b/modules/AboutHomeUtils.jsm new file mode 100644 index 0000000..72712e1 --- /dev/null +++ b/modules/AboutHomeUtils.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 = [ "AboutHomeUtils" ]; + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +this.AboutHomeUtils = { + /** + * Returns an object containing the name and searchURL of the original default + * search engine. + */ + get defaultSearchEngine() { + let defaultEngine = Services.search.defaultEngine; + let submission = defaultEngine.getSubmission("_searchTerms_", null, "homepage"); + + return Object.freeze({ + name: defaultEngine.name, + searchURL: submission.uri.spec, + postDataString: submission.postDataString + }); + }, + + /* + * 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) { } + +#ifndef MC_OFFICIAL + // Non-official builds shouldn't show the notification. + return false; +#endif + + // 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; + } +}; diff --git a/modules/AutoCompletePopup.jsm b/modules/AutoCompletePopup.jsm new file mode 100644 index 0000000..c3698f9 --- /dev/null +++ b/modules/AutoCompletePopup.jsm @@ -0,0 +1,293 @@ +/* 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 Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +this.EXPORTED_SYMBOLS = [ "AutoCompletePopup" ]; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +// nsITreeView implementation that feeds the autocomplete popup +// with the search data. +var AutoCompleteTreeView = { + // nsISupports + QueryInterface: XPCOMUtils.generateQI([Ci.nsITreeView, + Ci.nsIAutoCompleteController]), + + // Private variables + treeBox: null, + results: [], + + // nsITreeView + selection: null, + + get rowCount() { return this.results.length; }, + setTree: function(treeBox) { this.treeBox = treeBox; }, + getCellText: function(idx, column) { return this.results[idx].value }, + isContainer: function(idx) { return false; }, + getCellValue: function(idx, column) { return false }, + isContainerOpen: function(idx) { return false; }, + isContainerEmpty: function(idx) { return false; }, + isSeparator: function(idx) { return false; }, + isSorted: function() { return false; }, + isEditable: function(idx, column) { return false; }, + canDrop: function(idx, orientation, dt) { return false; }, + getLevel: function(idx) { return 0; }, + getParentIndex: function(idx) { return -1; }, + hasNextSibling: function(idx, after) { return idx < this.results.length - 1 }, + toggleOpenState: function(idx) { }, + getCellProperties: function(idx, column) { return this.results[idx].style || ""; }, + getRowProperties: function(idx) { return ""; }, + getImageSrc: function(idx, column) { return null; }, + getProgressMode : function(idx, column) { }, + cycleHeader: function(column) { }, + cycleCell: function(idx, column) { }, + selectionChanged: function() { }, + performAction: function(action) { }, + performActionOnCell: function(action, index, column) { }, + getColumnProperties: function(column) { return ""; }, + + // nsIAutoCompleteController + get matchCount() { + return this.rowCount; + }, + + handleEnter: function(aIsPopupSelection) { + AutoCompletePopup.handleEnter(aIsPopupSelection); + }, + + stopSearch: function() {}, + + // Internal JS-only API + clearResults: function() { + this.results = []; + }, + + setResults: function(results) { + this.results = results; + }, +}; + +this.AutoCompletePopup = { + MESSAGES: [ + "FormAutoComplete:SelectBy", + "FormAutoComplete:GetSelectedIndex", + "FormAutoComplete:SetSelectedIndex", + "FormAutoComplete:MaybeOpenPopup", + "FormAutoComplete:ClosePopup", + "FormAutoComplete:Disconnect", + "FormAutoComplete:RemoveEntry", + "FormAutoComplete:Invalidate", + ], + + init: function() { + for (let msg of this.MESSAGES) { + Services.mm.addMessageListener(msg, this); + } + }, + + uninit: function() { + for (let msg of this.MESSAGES) { + Services.mm.removeMessageListener(msg, this); + } + }, + + handleEvent: function(evt) { + switch (evt.type) { + case "popupshowing": { + this.sendMessageToBrowser("FormAutoComplete:PopupOpened"); + break; + } + + case "popuphidden": { + this.sendMessageToBrowser("FormAutoComplete:PopupClosed"); + this.openedPopup = null; + this.weakBrowser = null; + evt.target.removeEventListener("popuphidden", this); + evt.target.removeEventListener("popupshowing", this); + break; + } + } + }, + + // Along with being called internally by the receiveMessage handler, + // this function is also called directly by the login manager, which + // uses a single message to fill in the autocomplete results. See + // "RemoteLogins:autoCompleteLogins". + showPopupWithResults: function({ browser, rect, dir, results }) { + if (!results.length || this.openedPopup) { + // We shouldn't ever be showing an empty popup, and if we + // already have a popup open, the old one needs to close before + // we consider opening a new one. + return; + } + + let window = browser.ownerDocument.defaultView; + let tabbrowser = window.gBrowser; + if (Services.focus.activeWindow != window || + tabbrowser.selectedBrowser != browser) { + // We were sent a message from a window or tab that went into the + // background, so we'll ignore it for now. + return; + } + + this.weakBrowser = Cu.getWeakReference(browser); + this.openedPopup = browser.autoCompletePopup; + this.openedPopup.hidden = false; + // don't allow the popup to become overly narrow + this.openedPopup.setAttribute("width", Math.max(100, rect.width)); + this.openedPopup.style.direction = dir; + + AutoCompleteTreeView.setResults(results); + this.openedPopup.view = AutoCompleteTreeView; + this.openedPopup.selectedIndex = -1; + this.openedPopup.invalidate(); + + if (results.length) { + // Reset fields that were set from the last time the search popup was open + this.openedPopup.mInput = null; + this.openedPopup.showCommentColumn = false; + this.openedPopup.showImageColumn = false; + this.openedPopup.addEventListener("popuphidden", this); + this.openedPopup.addEventListener("popupshowing", this); + this.openedPopup.openPopupAtScreenRect("after_start", rect.left, rect.top, + rect.width, rect.height, false, + false); + } else { + this.closePopup(); + } + }, + + invalidate(results) { + if (!this.openedPopup) { + return; + } + + if (!results.length) { + this.closePopup(); + } else { + AutoCompleteTreeView.setResults(results); + // We need to re-set the view in order for the + // tree to know the view has changed. + this.openedPopup.view = AutoCompleteTreeView; + this.openedPopup.invalidate(); + } + }, + + closePopup() { + if (this.openedPopup) { + // Note that hidePopup() closes the popup immediately, + // so popuphiding or popuphidden events will be fired + // and handled during this call. + this.openedPopup.hidePopup(); + } + AutoCompleteTreeView.clearResults(); + }, + + removeLogin(login) { + Services.logins.removeLogin(login); + }, + + receiveMessage: function(message) { + if (!message.target.autoCompletePopup) { + // Returning false to pacify ESLint, but this return value is + // ignored by the messaging infrastructure. + return false; + } + + switch (message.name) { + case "FormAutoComplete:SelectBy": { + this.openedPopup.selectBy(message.data.reverse, message.data.page); + break; + } + + case "FormAutoComplete:GetSelectedIndex": { + if (this.openedPopup) { + return this.openedPopup.selectedIndex; + } + // If the popup was closed, then the selection + // has not changed. + return -1; + } + + case "FormAutoComplete:SetSelectedIndex": { + let { index } = message.data; + if (this.openedPopup) { + this.openedPopup.selectedIndex = index; + } + break; + } + + case "FormAutoComplete:MaybeOpenPopup": { + let { results, rect, dir } = message.data; + this.showPopupWithResults({ browser: message.target, rect, dir, + results }); + break; + } + + case "FormAutoComplete:Invalidate": { + let { results } = message.data; + this.invalidate(results); + break; + } + + case "FormAutoComplete:ClosePopup": { + this.closePopup(); + break; + } + + case "FormAutoComplete:Disconnect": { + // The controller stopped controlling the current input, so clear + // any cached data. This is necessary cause otherwise we'd clear data + // only when starting a new search, but the next input could not support + // autocomplete and it would end up inheriting the existing data. + AutoCompleteTreeView.clearResults(); + break; + } + } + // Returning false to pacify ESLint, but this return value is + // ignored by the messaging infrastructure. + return false; + }, + + /** + * Despite its name, this handleEnter is only called when the user clicks on + * one of the items in the popup since the popup is rendered in the parent process. + * The real controller's handleEnter is called directly in the content process + * for other methods of completing a selection (e.g. using the tab or enter + * keys) since the field with focus is in that process. + */ + handleEnter(aIsPopupSelection) { + if (this.openedPopup) { + this.sendMessageToBrowser("FormAutoComplete:HandleEnter", { + selectedIndex: this.openedPopup.selectedIndex, + isPopupSelection: aIsPopupSelection, + }); + } + }, + + /** + * If a browser exists that AutoCompletePopup knows about, + * sends it a message. Otherwise, this is a no-op. + * + * @param {string} msgName + * The name of the message to send. + * @param {object} data + * The optional data to send with the message. + */ + sendMessageToBrowser(msgName, data) { + let browser = this.weakBrowser ? this.weakBrowser.get() + : null; + if (browser) { + browser.messageManager.sendAsyncMessage(msgName, data); + } + }, + + stopSearch: function() {} +} diff --git a/modules/BrowserNewTabPreloader.jsm b/modules/BrowserNewTabPreloader.jsm new file mode 100644 index 0000000..778698f --- /dev/null +++ b/modules/BrowserNewTabPreloader.jsm @@ -0,0 +1,436 @@ +/* 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 = ["BrowserNewTabPreloader"]; + +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"); +Cu.import("resource://gre/modules/Promise.jsm"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const XUL_PAGE = "data:application/vnd.mozilla.xul+xml;charset=utf-8,"; +const NEWTAB_URL = "about:newtab"; +const PREF_BRANCH = "browser.newtab."; + +// The interval between swapping in a preload docShell and kicking off the +// next preload in the background. +const PRELOADER_INTERVAL_MS = 600; +// The initial delay before we start preloading our first new tab page. The +// timer is started after the first 'browser-delayed-startup' has been sent. +const PRELOADER_INIT_DELAY_MS = 5000; +// The number of miliseconds we'll wait after we received a notification that +// causes us to update our list of browsers and tabbrowser sizes. This acts as +// kind of a damper when too many events are occuring in quick succession. +const PRELOADER_UPDATE_DELAY_MS = 3000; + +const TOPIC_TIMER_CALLBACK = "timer-callback"; +const TOPIC_DELAYED_STARTUP = "browser-delayed-startup-finished"; +const TOPIC_XUL_WINDOW_CLOSED = "xul-window-destroyed"; + +function createTimer(obj, delay) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init(obj, delay, Ci.nsITimer.TYPE_ONE_SHOT); + return timer; +} + +function clearTimer(timer) { + if (timer) { + timer.cancel(); + } + return null; +} + +this.BrowserNewTabPreloader = { + init: function Preloader_init() { + Initializer.start(); + }, + + uninit: function Preloader_uninit() { + Initializer.stop(); + HostFrame.destroy(); + Preferences.uninit(); + HiddenBrowsers.uninit(); + }, + + newTab: function Preloader_newTab(aTab) { + let win = aTab.ownerDocument.defaultView; + if (win.gBrowser) { + let utils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + + let {width, height} = utils.getBoundsWithoutFlushing(win.gBrowser); + let hiddenBrowser = HiddenBrowsers.get(width, height) + if (hiddenBrowser) { + return hiddenBrowser.swapWithNewTab(aTab); + } + } + + return false; + } +}; + +Object.freeze(BrowserNewTabPreloader); + +var Initializer = { + _timer: null, + _observing: false, + + start: function Initializer_start() { + Services.obs.addObserver(this, TOPIC_DELAYED_STARTUP, false); + this._observing = true; + }, + + stop: function Initializer_stop() { + this._timer = clearTimer(this._timer); + + if (this._observing) { + Services.obs.removeObserver(this, TOPIC_DELAYED_STARTUP); + this._observing = false; + } + }, + + observe: function Initializer_observe(aSubject, aTopic, aData) { + if (aTopic == TOPIC_DELAYED_STARTUP) { + Services.obs.removeObserver(this, TOPIC_DELAYED_STARTUP); + this._observing = false; + this._startTimer(); + } else if (aTopic == TOPIC_TIMER_CALLBACK) { + this._timer = null; + this._startPreloader(); + } + }, + + _startTimer: function Initializer_startTimer() { + this._timer = createTimer(this, PRELOADER_INIT_DELAY_MS); + }, + + _startPreloader: function Initializer_startPreloader() { + Preferences.init(); + if (Preferences.enabled) { + HiddenBrowsers.init(); + } + } +}; + +var Preferences = { + _enabled: null, + _branch: null, + + get enabled() { + if (this._enabled === null) { + this._enabled = this._branch.getBoolPref("preload") && + !this._branch.prefHasUserValue("url"); + } + + return this._enabled; + }, + + init: function Preferences_init() { + this._branch = Services.prefs.getBranch(PREF_BRANCH); + this._branch.addObserver("", this, false); + }, + + uninit: function Preferences_uninit() { + if (this._branch) { + this._branch.removeObserver("", this); + this._branch = null; + } + }, + + observe: function Preferences_observe() { + let prevEnabled = this._enabled; + this._enabled = null; + + if (prevEnabled && !this.enabled) { + HiddenBrowsers.uninit(); + } else if (!prevEnabled && this.enabled) { + HiddenBrowsers.init(); + } + }, +}; + +var HiddenBrowsers = { + _browsers: null, + _updateTimer: null, + + _topics: [ + TOPIC_DELAYED_STARTUP, + TOPIC_XUL_WINDOW_CLOSED + ], + + init: function () { + this._browsers = new Map(); + this._updateBrowserSizes(); + this._topics.forEach(t => Services.obs.addObserver(this, t, false)); + }, + + uninit: function () { + if (this._browsers) { + this._topics.forEach(t => Services.obs.removeObserver(this, t, false)); + this._updateTimer = clearTimer(this._updateTimer); + + for (let [key, browser] of this._browsers) { + browser.destroy(); + } + this._browsers = null; + } + }, + + get: function (width, height) { + // We haven't been initialized, yet. + if (!this._browsers) { + return null; + } + + let key = width + "x" + height; + if (!this._browsers.has(key)) { + // Update all browsers' sizes if we can't find a matching one. + this._updateBrowserSizes(); + } + + // We should now have a matching browser. + if (this._browsers.has(key)) { + return this._browsers.get(key); + } + + // We should never be here. Return the first browser we find. + Cu.reportError("NewTabPreloader: no matching browser found after updating"); + for (let [size, browser] of this._browsers) { + return browser; + } + + // We should really never be here. + Cu.reportError("NewTabPreloader: not even a single browser was found?"); + return null; + }, + + observe: function (subject, topic, data) { + if (topic === TOPIC_TIMER_CALLBACK) { + this._updateTimer = null; + this._updateBrowserSizes(); + } else { + this._updateTimer = clearTimer(this._updateTimer); + this._updateTimer = createTimer(this, PRELOADER_UPDATE_DELAY_MS); + } + }, + + _updateBrowserSizes: function () { + let sizes = this._collectTabBrowserSizes(); + let toRemove = []; + + // Iterate all browsers and check that they + // each can be assigned to one of the sizes. + for (let [key, browser] of this._browsers) { + if (sizes.has(key)) { + // We already have a browser for that size, great! + sizes.delete(key); + } else { + // This browser is superfluous or needs to be resized. + toRemove.push(browser); + this._browsers.delete(key); + } + } + + // Iterate all sizes that we couldn't find a browser for. + for (let [key, {width, height}] of sizes) { + let browser; + if (toRemove.length) { + // Let's just resize one of the superfluous + // browsers and put it back into the map. + browser = toRemove.shift(); + browser.resize(width, height); + } else { + // No more browsers to reuse, create a new one. + browser = new HiddenBrowser(width, height); + } + + this._browsers.set(key, browser); + } + + // Finally, remove all browsers we don't need anymore. + toRemove.forEach(b => b.destroy()); + }, + + _collectTabBrowserSizes: function () { + let sizes = new Map(); + + function tabBrowserBounds() { + let wins = Services.ww.getWindowEnumerator("navigator:browser"); + while (wins.hasMoreElements()) { + let win = wins.getNext(); + if (win.gBrowser) { + let utils = win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + yield utils.getBoundsWithoutFlushing(win.gBrowser); + } + } + } + + // Collect the sizes of all s out there. + for (let {width, height} of tabBrowserBounds()) { + if (width > 0 && height > 0) { + let key = width + "x" + height; + if (!sizes.has(key)) { + sizes.set(key, {width: width, height: height}); + } + } + } + + return sizes; + } +}; + +function HiddenBrowser(width, height) { + this.resize(width, height); + + HostFrame.get().then(aFrame => { + let doc = aFrame.document; + this._browser = doc.createElementNS(XUL_NS, "browser"); + this._browser.setAttribute("type", "content"); + this._browser.setAttribute("src", NEWTAB_URL); + this._applySize(); + doc.getElementById("win").appendChild(this._browser); + }); +} + +HiddenBrowser.prototype = { + _width: null, + _height: null, + _timer: null, + _needsFrameScripts: true, + + get isPreloaded() { + return this._browser && + this._browser.contentDocument && + this._browser.contentDocument.readyState === "complete" && + this._browser.currentURI.spec === NEWTAB_URL; + }, + + swapWithNewTab: function (aTab) { + if (!this.isPreloaded || this._timer) { + return false; + } + + let win = aTab.ownerDocument.defaultView; + let tabbrowser = win.gBrowser; + + if (!tabbrowser) { + return false; + } + + // Swap docShells. + tabbrowser.swapNewTabWithBrowser(aTab, this._browser); + + // Load all default frame scripts. + if (this._needsFrameScripts) { + this._needsFrameScripts = false; + + let mm = aTab.linkedBrowser.messageManager; + mm.loadFrameScript("chrome://browser/content/content.js", true); + mm.loadFrameScript("chrome://browser/content/content-sessionStore.js", true); + + if ("TabView" in win) { + mm.loadFrameScript("chrome://browser/content/tabview-content.js", true); + } + } + + // Start a timer that will kick off preloading the next newtab page. + this._timer = createTimer(this, PRELOADER_INTERVAL_MS); + + // Signal that we swapped docShells. + return true; + }, + + observe: function () { + this._timer = null; + + // Start pre-loading the new tab page. + this._browser.loadURI(NEWTAB_URL); + }, + + resize: function (width, height) { + this._width = width; + this._height = height; + this._applySize(); + }, + + _applySize: function () { + if (this._browser) { + this._browser.style.width = this._width + "px"; + this._browser.style.height = this._height + "px"; + } + }, + + destroy: function () { + if (this._browser) { + this._browser.remove(); + this._browser = null; + } + + this._timer = clearTimer(this._timer); + } +}; + +var HostFrame = { + _frame: null, + _deferred: null, + + get hiddenDOMDocument() { + return Services.appShell.hiddenDOMWindow.document; + }, + + get isReady() { + return this.hiddenDOMDocument.readyState === "complete"; + }, + + get: function () { + if (!this._deferred) { + this._deferred = Promise.defer(); + this._create(); + } + + return this._deferred.promise; + }, + + destroy: function () { + 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 { + let flags = Ci.nsIThread.DISPATCH_NORMAL; + Services.tm.currentThread.dispatch(() => this._create(), flags); + } + } +}; diff --git a/modules/CharsetMenu.jsm b/modules/CharsetMenu.jsm new file mode 100644 index 0000000..f973088 --- /dev/null +++ b/modules/CharsetMenu.jsm @@ -0,0 +1,160 @@ +/* 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 = [ "CharsetMenu" ]; + +const { classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyGetter(this, "gBundle", function() { + const kUrl = "chrome://browser/locale/charsetMenu.properties"; + return Services.strings.createBundle(kUrl); +}); +/** + * This set contains encodings that are in the Encoding Standard, except: + * - XSS-dangerous encodings (except ISO-2022-JP which is assumed to be + * too common not to be included). + * - x-user-defined, which practically never makes sense as an end-user-chosen + * override. + * - Encodings that IE11 doesn't have in its correspoding menu. + */ +const kEncodings = new Set([ + // Globally relevant + "UTF-8", + "windows-1252", + // Arabic + "windows-1256", + "ISO-8859-6", + // Baltic + "windows-1257", + "ISO-8859-4", + // "ISO-8859-13", // Hidden since not in menu in IE11 + // Central European + "windows-1250", + "ISO-8859-2", + // Chinese, Simplified + "gbk", + "gb18030", + // Chinese, Traditional + "Big5", + // Cyrillic + "windows-1251", + "ISO-8859-5", + "KOI8-R", + "KOI8-U", + "IBM866", // Not in menu in Chromium. Maybe drop this? + // "x-mac-cyrillic", // Not in menu in IE11 or Chromium. + // Greek + "windows-1253", + "ISO-8859-7", + // Hebrew + "windows-1255", + "ISO-8859-8-I", + "ISO-8859-8", + // Japanese + "Shift_JIS", + "EUC-JP", + "ISO-2022-JP", + // Korean + "EUC-KR", + // Thai + "windows-874", + // Turkish + "windows-1254", + // Vietnamese + "windows-1258", + // Hiding rare European encodings that aren't in the menu in IE11 and would + // make the menu messy by sorting all over the place + // "ISO-8859-3", + // "ISO-8859-10", + // "ISO-8859-14", + // "ISO-8859-15", + // "ISO-8859-16", + // "macintosh" +]); + +// Always at the start of the menu, in this order, followed by a separator. +const kPinned = [ + "UTF-8", + "windows-1252" +]; + +this.CharsetMenu = Object.freeze({ + build: function BuildCharsetMenu(event) { + let parent = event.target; + if (parent.lastChild.localName != "menuseparator") { + // Detector menu or charset menu already built + return; + } + let doc = parent.ownerDocument; + + function createItem(encoding) { + let menuItem = doc.createElement("menuitem"); + menuItem.setAttribute("type", "radio"); + menuItem.setAttribute("name", "charsetGroup"); + try { + menuItem.setAttribute("label", gBundle.GetStringFromName(encoding)); + } catch (e) { + // Localization error but put *something* in the menu to recover. + menuItem.setAttribute("label", encoding); + } + try { + menuItem.setAttribute("accesskey", + gBundle.GetStringFromName(encoding + ".key")); + } catch (e) { + // Some items intentionally don't have an accesskey + } + menuItem.setAttribute("id", "charset." + encoding); + return menuItem; + } + + // Clone the set in order to be able to remove the pinned encodings from + // the cloned set. + let encodings = new Set(kEncodings); + for (let encoding of kPinned) { + encodings.delete(encoding); + parent.appendChild(createItem(encoding)); + } + parent.appendChild(doc.createElement("menuseparator")); + let list = []; + for (let encoding of encodings) { + list.push(createItem(encoding)); + } + + list.sort(function (a, b) { + let titleA = a.getAttribute("label"); + let titleB = b.getAttribute("label"); + // Normal sorting sorts the part in parenthesis in an order that + // happens to make the less frequently-used items first. + let index; + if ((index = titleA.indexOf("(")) > -1) { + titleA = titleA.substring(0, index); + } + if ((index = titleB.indexOf("(")) > -1) { + titleA = titleB.substring(0, index); + } + let comp = titleA.localeCompare(titleB); + if (comp) { + return comp; + } + // secondarily reverse sort by encoding name to sort "windows" or + // "shift_jis" first. This works regardless of localization, because + // the ids aren't localized. + let idA = a.getAttribute("id"); + let idB = b.getAttribute("id"); + if (idA < idB) { + return 1; + } + if (idB < idA) { + return -1; + } + return 0; + }); + + for (let item of list) { + parent.appendChild(item); + } + }, +}); \ No newline at end of file diff --git a/modules/FormSubmitObserver.jsm b/modules/FormSubmitObserver.jsm new file mode 100644 index 0000000..6b2ea3c --- /dev/null +++ b/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; + } + + // Ensure 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/modules/FormValidationHandler.jsm b/modules/FormValidationHandler.jsm new file mode 100644 index 0000000..387c221 --- /dev/null +++ b/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.ownerDocument.defaultView; + let json = aMessage.json; + let tabBrowser = window.gBrowser; + switch (aMessage.name) { + case "FormValidation:ShowPopup": + // target is the , 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/modules/NetworkPrioritizer.jsm b/modules/NetworkPrioritizer.jsm new file mode 100644 index 0000000..23d688a --- /dev/null +++ b/modules/NetworkPrioritizer.jsm @@ -0,0 +1,179 @@ +/* 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 = ["TabOpen", "TabSelect"]; +const WINDOW_EVENTS = ["activate", "unload"]; +// PRIORITY DELTA is -10 because lower priority value is actually a higher priority +const PRIORITY_DELTA = -10; + + +// Variables +var _lastFocusedWindow = null; +var _windows = []; + + +// Exported symbol +this.trackBrowserWindow = function trackBrowserWindow(aWindow) { + WindowHelper.addWindow(aWindow); +} + + +// Global methods +function _handleEvent(aEvent) { + switch (aEvent.type) { + case "TabOpen": + BrowserHelper.onOpen(aEvent.target.linkedBrowser); + break; + case "TabSelect": + BrowserHelper.onSelect(aEvent.target.linkedBrowser); + break; + case "activate": + WindowHelper.onActivate(aEvent.target); + 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) { + // If the tab is in the focused window, leave priority as it is + if (aBrowser.ownerDocument.defaultView != _lastFocusedWindow) + this.decreasePriority(aBrowser); + }, + + onSelect: function NP_BH_onSelect(aBrowser) { + let windowEntry = WindowHelper.getEntry(aBrowser.ownerDocument.defaultView); + if (windowEntry.lastSelectedBrowser) + this.decreasePriority(windowEntry.lastSelectedBrowser); + this.increasePriority(aBrowser); + + windowEntry.lastSelectedBrowser = aBrowser; + }, + + increasePriority: function NP_BH_increasePriority(aBrowser) { + aBrowser.adjustPriority(PRIORITY_DELTA); + }, + + decreasePriority: function NP_BH_decreasePriority(aBrowser) { + aBrowser.adjustPriority(PRIORITY_DELTA * -1); + } +}; + + +// 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/modules/PageMenu.jsm b/modules/PageMenu.jsm new file mode 100644 index 0000000..d01f626 --- /dev/null +++ b/modules/PageMenu.jsm @@ -0,0 +1,238 @@ +/* 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 = ["PageMenu"]; + +this.PageMenu = function PageMenu() { +} + +PageMenu.prototype = { + PAGEMENU_ATTR: "pagemenu", + GENERATEDITEMID_ATTR: "generateditemid", + + _popup: null, + _builder: null, + + // Given a target node, get the context menu for it or its ancestor. + getContextMenu: function(aTarget) { + let target = aTarget; + while (target) { + let contextMenu = target.contextMenu; + if (contextMenu) { + return contextMenu; + } + target = target.parentNode; + } + + return null; + }, + + // Given a target node, generate a JSON object for any context menu + // associated with it, or null if there is no context menu. + maybeBuild: function(aTarget) { + let pageMenu = this.getContextMenu(aTarget); + if (!pageMenu) { + return null; + } + + pageMenu.QueryInterface(Components.interfaces.nsIHTMLMenu); + pageMenu.sendShowEvent(); + // the show event is not cancelable, so no need to check a result here + + this._builder = pageMenu.createBuilder(); + if (!this._builder) { + return null; + } + + pageMenu.build(this._builder); + + // This serializes then parses again, however this could be avoided in + // the single-process case with further improvement. + let menuString = this._builder.toJSONString(); + if (!menuString) { + return null; + } + + return JSON.parse(menuString); + }, + + // Given a JSON menu object and popup, add the context menu to the popup. + buildAndAttachMenuWithObject: function(aMenu, aBrowser, aPopup) { + if (!aMenu) { + return false; + } + + let insertionPoint = this.getInsertionPoint(aPopup); + if (!insertionPoint) { + return false; + } + + let fragment = aPopup.ownerDocument.createDocumentFragment(); + this.buildXULMenu(aMenu, fragment); + + let pos = insertionPoint.getAttribute(this.PAGEMENU_ATTR); + if (pos == "start") { + insertionPoint.insertBefore(fragment, + insertionPoint.firstChild); + } else if (pos.startsWith("#")) { + insertionPoint.insertBefore(fragment, insertionPoint.querySelector(pos)); + } else { + insertionPoint.appendChild(fragment); + } + + this._popup = aPopup; + + this._popup.addEventListener("command", this); + this._popup.addEventListener("popuphidden", this); + + return true; + }, + + // Construct the XUL menu structure for a given JSON object. + buildXULMenu: function(aNode, aElementForAppending) { + let document = aElementForAppending.ownerDocument; + + let children = aNode.children; + for (let child of children) { + let menuitem; + switch (child.type) { + case "menuitem": + if (!child.id) { + continue; // Ignore children without ids + } + + menuitem = document.createElement("menuitem"); + if (child.checkbox) { + menuitem.setAttribute("type", "checkbox"); + if (child.checked) { + menuitem.setAttribute("checked", "true"); + } + } + + if (child.label) { + menuitem.setAttribute("label", child.label); + } + if (child.icon) { + menuitem.setAttribute("image", child.icon); + menuitem.className = "menuitem-iconic"; + } + if (child.disabled) { + menuitem.setAttribute("disabled", true); + } + + break; + + case "separator": + menuitem = document.createElement("menuseparator"); + break; + + case "menu": + menuitem = document.createElement("menu"); + if (child.label) { + menuitem.setAttribute("label", child.label); + } + + let menupopup = document.createElement("menupopup"); + menuitem.appendChild(menupopup); + + this.buildXULMenu(child, menupopup); + break; + } + + menuitem.setAttribute(this.GENERATEDITEMID_ATTR, child.id ? child.id : 0); + aElementForAppending.appendChild(menuitem); + } + }, + + // Called when the generated menuitem is executed. + handleEvent: function(event) { + let type = event.type; + let target = event.target; + if (type == "command" && target.hasAttribute(this.GENERATEDITEMID_ATTR)) { + // If a builder is assigned, call click on it directly. Otherwise, this is + // likely a menu with data from another process, so send a message to the + // browser to execute the menuitem. + if (this._builder) { + this._builder.click(target.getAttribute(this.GENERATEDITEMID_ATTR)); + } + } else if (type == "popuphidden" && this._popup == target) { + this.removeGeneratedContent(this._popup); + + this._popup.removeEventListener("popuphidden", this); + this._popup.removeEventListener("command", this); + + this._popup = null; + this._builder = null; + } + }, + + // Get the first child of the given element with the given tag name. + getImmediateChild: function(element, tag) { + let child = element.firstChild; + while (child) { + if (child.localName == tag) { + return child; + } + child = child.nextSibling; + } + return null; + }, + + // Return the location where the generated items should be inserted into the + // given popup. They should be inserted as the next sibling of the returned + // element. + getInsertionPoint: function(aPopup) { + if (aPopup.hasAttribute(this.PAGEMENU_ATTR)) + return aPopup; + + let element = aPopup.firstChild; + while (element) { + if (element.localName == "menu") { + let popup = this.getImmediateChild(element, "menupopup"); + if (popup) { + let result = this.getInsertionPoint(popup); + if (result) { + return result; + } + } + } + element = element.nextSibling; + } + + return null; + }, + + // Returns true if custom menu items were present. + maybeBuildAndAttachMenu: function(aTarget, aPopup) { + let menuObject = this.maybeBuild(aTarget); + if (!menuObject) { + return false; + } + + return this.buildAndAttachMenuWithObject(menuObject, null, aPopup); + }, + + // Remove the generated content from the given popup. + removeGeneratedContent: function(aPopup) { + let ungenerated = []; + ungenerated.push(aPopup); + + let count; + while (0 != (count = ungenerated.length)) { + let last = count - 1; + let element = ungenerated[last]; + ungenerated.splice(last, 1); + + let i = element.childNodes.length; + while (i-- > 0) { + let child = element.childNodes[i]; + if (!child.hasAttribute(this.GENERATEDITEMID_ATTR)) { + ungenerated.push(child); + continue; + } + element.removeChild(child); + } + } + } +} diff --git a/modules/PopupNotifications.jsm b/modules/PopupNotifications.jsm new file mode 100644 index 0000000..0cb9702 --- /dev/null +++ b/modules/PopupNotifications.jsm @@ -0,0 +1,994 @@ +/* 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 = ["PopupNotifications"]; + +var Cc = Components.classes, Ci = Components.interfaces, Cu = Components.utils; + +Cu.import("resource://gre/modules/Services.jsm"); + +const NOTIFICATION_EVENT_DISMISSED = "dismissed"; +const NOTIFICATION_EVENT_REMOVED = "removed"; +const NOTIFICATION_EVENT_SHOWING = "showing"; +const NOTIFICATION_EVENT_SHOWN = "shown"; +const NOTIFICATION_EVENT_SWAPPING = "swapping"; + +const ICON_SELECTOR = ".notification-anchor-icon"; +const ICON_ATTRIBUTE_SHOWING = "showing"; + +const PREF_SECURITY_DELAY = "security.notification_enable_delay"; + +var popupNotificationsMap = new WeakMap(); +var gNotificationParents = new WeakMap; + +function getAnchorFromBrowser(aBrowser) { + let anchor = aBrowser.getAttribute("popupnotificationanchor") || + aBrowser.popupnotificationanchor; + if (anchor) { + if (anchor instanceof Ci.nsIDOMXULElement) { + return anchor; + } + return aBrowser.ownerDocument.getElementById(anchor); + } + return null; +} + +function getNotificationFromElement(aElement) { + // Need to find the associated notification object, which is a bit tricky + // since it isn't associated with the element directly - this is kind of + // gross and very dependent on the structure of the popupnotification + // binding's content. + let notificationEl; + let parent = aElement; + while (parent && (parent = aElement.ownerDocument.getBindingParent(parent))) + notificationEl = parent; + return notificationEl; +} + +/** + * Notification object describes a single popup notification. + * + * @see PopupNotifications.show() + */ +function Notification(id, message, anchorID, mainAction, secondaryActions, + browser, owner, options) { + this.id = id; + this.message = message; + this.anchorID = anchorID; + this.mainAction = mainAction; + this.secondaryActions = secondaryActions || []; + this.browser = browser; + this.owner = owner; + this.options = options || {}; +} + +Notification.prototype = { + + id: null, + message: null, + anchorID: null, + mainAction: null, + secondaryActions: null, + browser: null, + owner: null, + options: null, + timeShown: null, + + /** + * Removes the notification and updates the popup accordingly if needed. + */ + remove: function Notification_remove() { + this.owner.remove(this); + }, + + get anchorElement() { + let iconBox = this.owner.iconBox; + + let anchorElement = getAnchorFromBrowser(this.browser); + + if (!iconBox) + return anchorElement; + + if (!anchorElement && this.anchorID) + anchorElement = iconBox.querySelector("#"+this.anchorID); + + // Use a default anchor icon if it's available + if (!anchorElement) + anchorElement = iconBox.querySelector("#default-notification-icon") || + iconBox; + + return anchorElement; + }, + + reshow: function() { + this.owner._reshowNotifications(this.anchorElement, this.browser); + } +}; + +/** + * The PopupNotifications object manages popup notifications for a given browser + * window. + * @param tabbrowser + * window's . Used to observe tab switching events and + * for determining the active browser element. + * @param panel + * The element to use for notifications. The panel is + * populated with children and displayed it as + * needed. + * @param iconBox + * Reference to a container element that should be hidden or + * unhidden when notifications are hidden or shown. It should be the + * parent of anchor elements whose IDs are passed to show(). + * It is used as a fallback popup anchor if notifications specify + * invalid or non-existent anchor IDs. + */ +this.PopupNotifications = function PopupNotifications(tabbrowser, panel, iconBox) { + if (!(tabbrowser instanceof Ci.nsIDOMXULElement)) + throw "Invalid tabbrowser"; + if (iconBox && !(iconBox instanceof Ci.nsIDOMXULElement)) + throw "Invalid iconBox"; + if (!(panel instanceof Ci.nsIDOMXULElement)) + throw "Invalid panel"; + + this.window = tabbrowser.ownerDocument.defaultView; + this.panel = panel; + this.tabbrowser = tabbrowser; + this.iconBox = iconBox; + this.buttonDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY); + + this.panel.addEventListener("popuphidden", this, true); + + this.window.addEventListener("activate", this, true); + if (this.tabbrowser.tabContainer) + this.tabbrowser.tabContainer.addEventListener("TabSelect", this, true); +} + +PopupNotifications.prototype = { + + window: null, + panel: null, + tabbrowser: null, + + _iconBox: null, + set iconBox(iconBox) { + // Remove the listeners on the old iconBox, if needed + if (this._iconBox) { + this._iconBox.removeEventListener("click", this, false); + this._iconBox.removeEventListener("keypress", this, false); + } + this._iconBox = iconBox; + if (iconBox) { + iconBox.addEventListener("click", this, false); + iconBox.addEventListener("keypress", this, false); + } + }, + get iconBox() { + return this._iconBox; + }, + + /** + * Retrieve a Notification object associated with the browser/ID pair. + * @param id + * The Notification ID to search for. + * @param browser + * The browser whose notifications should be searched. If null, the + * currently selected browser's notifications will be searched. + * + * @returns the corresponding Notification object, or null if no such + * notification exists. + */ + getNotification: function PopupNotifications_getNotification(id, browser) { + let n = null; + let notifications = this._getNotificationsForBrowser(browser || this.tabbrowser.selectedBrowser); + notifications.some(function(x) x.id == id && (n = x)); + return n; + }, + + /** + * Adds a new popup notification. + * @param browser + * The element associated with the notification. Must not + * be null. + * @param id + * A unique ID that identifies the type of notification (e.g. + * "geolocation"). Only one notification with a given ID can be visible + * at a time. If a notification already exists with the given ID, it + * will be replaced. + * @param message + * The text to be displayed in the notification. + * @param anchorID + * The ID of the element that should be used as this notification + * popup's anchor. May be null, in which case the notification will be + * anchored to the iconBox. + * @param mainAction + * A JavaScript object literal describing the notification button's + * action. If present, it must have the following properties: + * - label (string): the button's label. + * - accessKey (string): the button's accessKey. + * - callback (function): a callback to be invoked when the button is + * pressed, is passed an object that contains the following fields: + * - checkboxChecked: (boolean) If the optional checkbox is checked. + * If null, the notification will not have a button, and + * secondaryActions will be ignored. + * @param secondaryActions + * An optional JavaScript array describing the notification's alternate + * actions. The array should contain objects with the same properties + * as mainAction. These are used to populate the notification button's + * dropdown menu. + * @param options + * An options JavaScript object holding additional properties for the + * notification. The following properties are currently supported: + * persistence: An integer. The notification will not automatically + * dismiss for this many page loads. + * timeout: A time in milliseconds. The notification will not + * automatically dismiss before this time. + * persistWhileVisible: + * A boolean. If true, a visible notification will always + * persist across location changes. + * dismissed: Whether the notification should be added as a dismissed + * notification. Dismissed notifications can be activated + * by clicking on their anchorElement. + * eventCallback: + * Callback to be invoked when the notification changes + * state. The callback's first argument is a string + * identifying the state change: + * "dismissed": notification has been dismissed by the + * user (e.g. by clicking away or switching + * tabs) + * "removed": notification has been removed (due to + * location change or user action) + * "showing": notification is about to be shown + * (this can be fired multiple times as + * notifications are dismissed and re-shown) + * "shown": notification has been shown (this can be fired + * multiple times as notifications are dismissed + * and re-shown) + * "swapping": the docshell of the browser that created + * the notification is about to be swapped to + * another browser. A second parameter contains + * the browser that is receiving the docshell, + * so that the event callback can transfer stuff + * specific to this notification. + * If the callback returns true, the notification + * will be moved to the new browser. + * If the callback isn't implemented, returns false, + * or doesn't return any value, the notification + * will be removed. + * neverShow: Indicate that no popup should be shown for this + * notification. Useful for just showing the anchor icon. + * removeOnDismissal: + * Notifications with this parameter set to true will be + * removed when they would have otherwise been dismissed + * (i.e. any time the popup is closed due to user + * interaction). + * checkbox: An object that allows you to add a checkbox and + * control its behavior with these fields: + * label: + * (required) Label to be shown next to the checkbox. + * checked: + * (optional) Whether the checkbox should be checked + * by default. Defaults to false. + * checkedState: + * (optional) An object that allows you to customize + * the notification state when the checkbox is checked. + * disableMainAction: + * (optional) Whether the mainAction is disabled. + * Defaults to false. + * warningLabel: + * (optional) A (warning) text that is shown below the + * checkbox. Pass null to hide. + * uncheckedState: + * (optional) An object that allows you to customize + * the notification state when the checkbox is not checked. + * Has the same attributes as checkedState. + * popupIconURL: + * A string. URL of the image to be displayed in the popup. + * Normally specified in CSS using list-style-image and the + * .popup-notification-icon[popupid=...] selector. + * learnMoreURL: + * A string URL. Setting this property will make the + * prompt display a "Learn More" link that, when clicked, + * opens the URL in a new tab. + * @returns the Notification object corresponding to the added notification. + */ + show: function PopupNotifications_show(browser, id, message, anchorID, + mainAction, secondaryActions, options) { + function isInvalidAction(a) { + return !a || !(typeof(a.callback) == "function") || !a.label || !a.accessKey; + } + + if (!browser) + throw "PopupNotifications_show: invalid browser"; + if (!id) + throw "PopupNotifications_show: invalid ID"; + if (mainAction && isInvalidAction(mainAction)) + throw "PopupNotifications_show: invalid mainAction"; + if (secondaryActions && secondaryActions.some(isInvalidAction)) + throw "PopupNotifications_show: invalid secondaryActions"; + + let notification = new Notification(id, message, anchorID, mainAction, + secondaryActions, browser, this, options); + + if (options && options.dismissed) + notification.dismissed = true; + + let existingNotification = this.getNotification(id, browser); + if (existingNotification) + this._remove(existingNotification); + + let notifications = this._getNotificationsForBrowser(browser); + notifications.push(notification); + + let fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager); + if (browser.docShell.isActive && fm.activeWindow == this.window) { + // show panel now + this._update(notifications, notification.anchorElement, true); + } else { + // Otherwise, update() will display the notification the next time the + // relevant tab/window is selected. + + // If the tab is selected but the window is in the background, let the OS + // tell the user that there's a notification waiting in that window. + // At some point we might want to do something about background tabs here + // too. When the user switches to this window, we'll show the panel if + // this browser is a tab (thus showing the anchor icon). For + // non-tabbrowser browsers, we need to make the icon visible now or the + // user will not be able to open the panel. + if (!notification.dismissed && browser.docShell.isActive) { + this.window.getAttention(); + if (notification.anchorElement.parentNode != this.iconBox) { + notification.anchorElement.setAttribute(ICON_ATTRIBUTE_SHOWING, "true"); + } + } + + // Notify observers that we're not showing the popup (useful for testing) + this._notify("backgroundShow"); + } + + return notification; + }, + + /** + * Returns true if the notification popup is currently being displayed. + */ + get isPanelOpen() { + let panelState = this.panel.state; + + return panelState == "showing" || panelState == "open"; + }, + + /** + * Called by the consumer to indicate that a browser's location has changed, + * so that we can update the active notifications accordingly. + */ + locationChange: function PopupNotifications_locationChange(aBrowser) { + if (!aBrowser) + throw "PopupNotifications_locationChange: invalid browser"; + + let notifications = this._getNotificationsForBrowser(aBrowser); + + notifications = notifications.filter(function (notification) { + // The persistWhileVisible option allows an open notification to persist + // across location changes + if (notification.options.persistWhileVisible && + this.isPanelOpen) { + if ("persistence" in notification.options && + notification.options.persistence) + notification.options.persistence--; + return true; + } + + // The persistence option allows a notification to persist across multiple + // page loads + if ("persistence" in notification.options && + notification.options.persistence) { + notification.options.persistence--; + return true; + } + + // The timeout option allows a notification to persist until a certain time + if ("timeout" in notification.options && + Date.now() <= notification.options.timeout) { + return true; + } + + this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED); + return false; + }, this); + + this._setNotificationsForBrowser(aBrowser, notifications); + + if (aBrowser.docShell.isActive) { + // get the anchor element if the browser has defined one so it will + // _update will handle both the tabs iconBox and non-tab permission + // anchors. + let anchorElement = notifications.length > 0 ? notifications[0].anchorElement : null; + if (!anchorElement) + anchorElement = getAnchorFromBrowser(aBrowser); + this._update(notifications, anchorElement); + } + }, + + /** + * Removes a Notification. + * @param notification + * The Notification object to remove. + */ + remove: function PopupNotifications_remove(notification) { + this._remove(notification); + + if (notification.browser.docShell.isActive) { + let notifications = this._getNotificationsForBrowser(notification.browser); + this._update(notifications, notification.anchorElement); + } + }, + + handleEvent: function (aEvent) { + switch (aEvent.type) { + case "popuphidden": + this._onPopupHidden(aEvent); + break; + case "activate": + case "TabSelect": + let self = this; + // setTimeout(..., 0) needed, otherwise openPopup from "activate" event + // handler results in the popup being hidden again for some reason... + this.window.setTimeout(function () { + self._update(); + }, 0); + break; + case "click": + case "keypress": + this._onIconBoxCommand(aEvent); + break; + } + }, + +//////////////////////////////////////////////////////////////////////////////// +// Utility methods +//////////////////////////////////////////////////////////////////////////////// + + _ignoreDismissal: null, + _currentAnchorElement: null, + + /** + * Gets notifications for the currently selected browser. + */ + get _currentNotifications() { + return this.tabbrowser.selectedBrowser ? this._getNotificationsForBrowser(this.tabbrowser.selectedBrowser) : []; + }, + + _remove: function PopupNotifications_removeHelper(notification) { + // This notification may already be removed, in which case let's just fail + // silently. + let notifications = this._getNotificationsForBrowser(notification.browser); + if (!notifications) + return; + + var index = notifications.indexOf(notification); + if (index == -1) + return; + + if (notification.browser.docShell.isActive) + notification.anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING); + + // remove the notification + notifications.splice(index, 1); + this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED); + }, + + /** + * Dismisses the notification without removing it. + */ + _dismiss: function PopupNotifications_dismiss() { + let browser = this.panel.firstChild && + this.panel.firstChild.notification.browser; + if (typeof this.panel.hidePopup === "function") { + this.panel.hidePopup(); + } + if (browser) + browser.focus(); + }, + + /** + * Hides the notification popup. + */ + _hidePanel: function PopupNotifications_hide() { + this._ignoreDismissal = true; + if (typeof this.panel.hidePopup === "function") { + this.panel.hidePopup(); + } + this._ignoreDismissal = false; + }, + + /** + * Removes all notifications from the notification popup. + */ + _clearPanel: function () { + let popupnotification; + while ((popupnotification = this.panel.lastChild)) { + this.panel.removeChild(popupnotification); + + // If this notification was provided by the chrome document rather than + // created ad hoc, move it back to where we got it from. + let originalParent = gNotificationParents.get(popupnotification); + if (originalParent) { + popupnotification.notification = null; + + // Remove nodes dynamically added to the notification's menu button + // in _refreshPanel. Keep popupnotificationcontent nodes; they are + // provided by the chrome document. + let contentNode = popupnotification.lastChild; + while (contentNode) { + let previousSibling = contentNode.previousSibling; + if (contentNode.nodeName != "popupnotificationcontent") + popupnotification.removeChild(contentNode); + contentNode = previousSibling; + } + + // Re-hide the notification such that it isn't rendered in the chrome + // document. _refreshPanel will unhide it again when needed. + popupnotification.hidden = true; + + originalParent.appendChild(popupnotification); + } + } + }, + + _refreshPanel: function PopupNotifications_refreshPanel(notificationsToShow) { + this._clearPanel(); + + const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + notificationsToShow.forEach(function (n) { + let doc = this.window.document; + + // Append "-notification" to the ID to try to avoid ID conflicts with other stuff + // in the document. + let popupnotificationID = n.id + "-notification"; + + // If the chrome document provides a popupnotification with this id, use + // that. Otherwise create it ad-hoc. + let popupnotification = doc.getElementById(popupnotificationID); + if (popupnotification) + gNotificationParents.set(popupnotification, popupnotification.parentNode); + else + popupnotification = doc.createElementNS(XUL_NS, "popupnotification"); + + popupnotification.setAttribute("label", n.message); + popupnotification.setAttribute("id", popupnotificationID); + popupnotification.setAttribute("popupid", n.id); + popupnotification.setAttribute("closebuttoncommand", "PopupNotifications._dismiss();"); + if (n.mainAction) { + popupnotification.setAttribute("buttonlabel", n.mainAction.label); + popupnotification.setAttribute("buttonaccesskey", n.mainAction.accessKey); + popupnotification.setAttribute("buttoncommand", "PopupNotifications._onButtonCommand(event);"); + popupnotification.setAttribute("menucommand", "PopupNotifications._onMenuCommand(event);"); + popupnotification.setAttribute("closeitemcommand", "PopupNotifications._dismiss();event.stopPropagation();"); + } else { + popupnotification.removeAttribute("buttonlabel"); + popupnotification.removeAttribute("buttonaccesskey"); + popupnotification.removeAttribute("buttoncommand"); + popupnotification.removeAttribute("menucommand"); + popupnotification.removeAttribute("closeitemcommand"); + } + + if (n.options.popupIconURL) + popupnotification.setAttribute("icon", n.options.popupIconURL); + if (n.options.learnMoreURL) + popupnotification.setAttribute("learnmoreurl", n.options.learnMoreURL); + else + popupnotification.removeAttribute("learnmoreurl"); + + popupnotification.notification = n; + + if (n.secondaryActions) { + n.secondaryActions.forEach(function (a) { + let item = doc.createElementNS(XUL_NS, "menuitem"); + item.setAttribute("label", a.label); + item.setAttribute("accesskey", a.accessKey); + item.notification = n; + item.action = a; + + popupnotification.appendChild(item); + }, this); + + if (n.secondaryActions.length) { + let closeItemSeparator = doc.createElementNS(XUL_NS, "menuseparator"); + popupnotification.appendChild(closeItemSeparator); + } + } + + let checkbox = n.options.checkbox; + if (checkbox && checkbox.label) { + let checked = n._checkboxChecked != null ? n._checkboxChecked : !!checkbox.checked; + + popupnotification.setAttribute("checkboxhidden", "false"); + popupnotification.setAttribute("checkboxchecked", checked); + popupnotification.setAttribute("checkboxlabel", checkbox.label); + + popupnotification.setAttribute("checkboxcommand", "PopupNotifications._onCheckboxCommand(event);"); + + if (checked) { + this._setNotificationUIState(popupnotification, checkbox.checkedState); + } else { + this._setNotificationUIState(popupnotification, checkbox.uncheckedState); + } + } else { + popupnotification.setAttribute("checkboxhidden", "true"); + } + + this.panel.appendChild(popupnotification); + + // The popupnotification may be hidden if we got it from the chrome + // document rather than creating it ad hoc. + popupnotification.hidden = false; + }, this); + }, + + _setNotificationUIState(notification, state={}) { + notification.setAttribute("mainactiondisabled", state.disableMainAction || "false"); + + if (state.warningLabel) { + notification.setAttribute("warninglabel", state.warningLabel); + notification.setAttribute("warninghidden", "false"); + } else { + notification.setAttribute("warninghidden", "true"); + } + }, + + _onCheckboxCommand(event) { + let notificationEl = getNotificationFromElement(event.originalTarget); + let checked = notificationEl.checkbox.checked; + let notification = notificationEl.notification; + + // Save checkbox state to be able to persist it when re-opening the doorhanger. + notification._checkboxChecked = checked; + + if (checked) { + this._setNotificationUIState(notificationEl, notification.options.checkbox.checkedState); + } else { + this._setNotificationUIState(notificationEl, notification.options.checkbox.uncheckedState); + } + }, + + _showPanel: function PopupNotifications_showPanel(notificationsToShow, anchorElement) { + this.panel.hidden = false; + + notificationsToShow.forEach(function (n) { + this._fireCallback(n, NOTIFICATION_EVENT_SHOWING); + }, this); + this._refreshPanel(notificationsToShow); + + if (this.isPanelOpen && this._currentAnchorElement == anchorElement) + return; + + // If the panel is already open but we're changing anchors, we need to hide + // it first. Otherwise it can appear in the wrong spot. (_hidePanel is + // safe to call even if the panel is already hidden.) + this._hidePanel(); + + // If the anchor element is hidden or null, use the tab as the anchor. We + // only ever show notifications for the current browser, so we can just use + // the current tab. + let selectedTab = this.tabbrowser.selectedTab; + if (anchorElement) { + let bo = anchorElement.boxObject; + if (bo.height == 0 && bo.width == 0) + anchorElement = selectedTab; // hidden + } else { + anchorElement = selectedTab; // null + } + + this._currentAnchorElement = anchorElement; + + // On OS X and Linux we need a different panel arrow color for + // click-to-play plugins, so copy the popupid and use css. + this.panel.setAttribute("popupid", this.panel.firstChild.getAttribute("popupid")); + notificationsToShow.forEach(function (n) { + // Remember the time the notification was shown for the security delay. + n.timeShown = this.window.performance.now(); + }, this); + this.panel.openPopup(anchorElement, "bottomcenter topleft"); + notificationsToShow.forEach(function (n) { + this._fireCallback(n, NOTIFICATION_EVENT_SHOWN); + }, this); + }, + + /** + * Updates the notification state in response to window activation or tab + * selection changes. + * + * @param notifications an array of Notification instances. if null, + * notifications will be retrieved off the current + * browser tab + * @param anchor is a XUL element that the notifications panel will be + * anchored to + * @param dismissShowing if true, dismiss any currently visible notifications + * if there are no notifications to show. Otherwise, + * currently displayed notifications will be left alone. + */ + _update: function PopupNotifications_update(notifications, anchor, dismissShowing = false) { + let useIconBox = this.iconBox && (!anchor || anchor.parentNode == this.iconBox); + if (useIconBox) { + // hide icons of the previous tab. + this._hideIcons(); + } + + let anchorElement = anchor, notificationsToShow = []; + if (!notifications) + notifications = this._currentNotifications; + let haveNotifications = notifications.length > 0; + if (haveNotifications) { + // Only show the notifications that have the passed-in anchor (or the + // first notification's anchor, if none was passed in). Other + // notifications will be shown once these are dismissed. + anchorElement = anchor || notifications[0].anchorElement; + + if (useIconBox) { + this._showIcons(notifications); + this.iconBox.hidden = false; + } else if (anchorElement) { + anchorElement.setAttribute(ICON_ATTRIBUTE_SHOWING, "true"); + // use the anchorID as a class along with the default icon class as a + // fallback if anchorID is not defined in CSS. We always use the first + // notifications icon, so in the case of multiple notifications we'll + // only use the default icon + if (anchorElement.classList.contains("notification-anchor-icon")) { + // remove previous icon classes + let className = anchorElement.className.replace(/([-\w]+-notification-icon\s?)/g,"") + className = "default-notification-icon " + className; + if (notifications.length == 1) { + className = notifications[0].anchorID + " " + className; + } + anchorElement.className = className; + } + } + + // Also filter out notifications that have been dismissed. + notificationsToShow = notifications.filter(function (n) { + return !n.dismissed && n.anchorElement == anchorElement && + !n.options.neverShow; + }); + } + + if (notificationsToShow.length > 0) { + this._showPanel(notificationsToShow, anchorElement); + } else { + // Notify observers that we're not showing the popup (useful for testing) + this._notify("updateNotShowing"); + + // Close the panel if there are no notifications to show. + // When called from PopupNotifications.show() we should never close the + // panel, however. It may just be adding a dismissed notification, in + // which case we want to continue showing any existing notifications. + if (!dismissShowing) + this._dismiss(); + + // Only hide the iconBox if we actually have no notifications (as opposed + // to not having any showable notifications) + if (!haveNotifications) { + if (useIconBox) + this.iconBox.hidden = true; + else if (anchorElement) + anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING); + } + } + }, + + _showIcons: function PopupNotifications_showIcons(aCurrentNotifications) { + for (let notification of aCurrentNotifications) { + let anchorElm = notification.anchorElement; + if (anchorElm) { + anchorElm.setAttribute(ICON_ATTRIBUTE_SHOWING, "true"); + } + } + }, + + _hideIcons: function PopupNotifications_hideIcons() { + let icons = this.iconBox.querySelectorAll(ICON_SELECTOR); + for (let icon of icons) { + icon.removeAttribute(ICON_ATTRIBUTE_SHOWING); + } + }, + + /** + * Gets and sets notifications for the browser. + */ + _getNotificationsForBrowser: function PopupNotifications_getNotifications(browser) { + let notifications = popupNotificationsMap.get(browser); + if (!notifications) { + // Initialize the WeakMap for the browser so callers can reference/manipulate the array. + notifications = []; + popupNotificationsMap.set(browser, notifications); + } + return notifications; + }, + _setNotificationsForBrowser: function PopupNotifications_setNotifications(browser, notifications) { + popupNotificationsMap.set(browser, notifications); + return notifications; + }, + + _onIconBoxCommand: function PopupNotifications_onIconBoxCommand(event) { + // Left click, space or enter only + let type = event.type; + if (type == "click" && event.button != 0) + return; + + if (type == "keypress" && + !(event.charCode == Ci.nsIDOMKeyEvent.DOM_VK_SPACE || + event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_RETURN)) + return; + + if (this._currentNotifications.length == 0) + return; + + // Get the anchor that is the immediate child of the icon box + let anchor = event.target; + while (anchor && anchor.parentNode != this.iconBox) + anchor = anchor.parentNode; + + this._reshowNotifications(anchor); + }, + + _reshowNotifications: function PopupNotifications_reshowNotifications(anchor, browser) { + // Mark notifications anchored to this anchor as un-dismissed + let notifications = this._getNotificationsForBrowser(browser || this.tabbrowser.selectedBrowser); + notifications.forEach(function (n) { + if (n.anchorElement == anchor) + n.dismissed = false; + }); + + // ...and then show them. + this._update(notifications, anchor); + }, + + _swapBrowserNotifications: function PopupNotifications_swapBrowserNoficications(ourBrowser, otherBrowser) { + // When swaping browser docshells (e.g. dragging tab to new window) we need + // to update our notification map. + + let ourNotifications = this._getNotificationsForBrowser(ourBrowser); + let other = otherBrowser.ownerDocument.defaultView.PopupNotifications; + if (!other) { + if (ourNotifications.length > 0) + Cu.reportError("unable to swap notifications: otherBrowser doesn't support notifications"); + return; + } + let otherNotifications = other._getNotificationsForBrowser(otherBrowser); + if (ourNotifications.length < 1 && otherNotifications.length < 1) { + // No notification to swap. + return; + } + + otherNotifications = otherNotifications.filter(n => { + if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, ourBrowser)) { + n.browser = ourBrowser; + n.owner = this; + return true; + } + other._fireCallback(n, NOTIFICATION_EVENT_REMOVED); + return false; + }); + + ourNotifications = ourNotifications.filter(n => { + if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, otherBrowser)) { + n.browser = otherBrowser; + n.owner = other; + return true; + } + this._fireCallback(n, NOTIFICATION_EVENT_REMOVED); + return false; + }); + + this._setNotificationsForBrowser(otherBrowser, ourNotifications); + other._setNotificationsForBrowser(ourBrowser, otherNotifications); + + if (otherNotifications.length > 0) + this._update(otherNotifications, otherNotifications[0].anchorElement); + if (ourNotifications.length > 0) + other._update(ourNotifications, ourNotifications[0].anchorElement); + }, + + _fireCallback: function PopupNotifications_fireCallback(n, event, ...args) { + try { + if (n.options.eventCallback) + return n.options.eventCallback.call(n, event, ...args); + } catch (error) { + Cu.reportError(error); + } + return undefined; + }, + + _onPopupHidden: function PopupNotifications_onPopupHidden(event) { + if (event.target != this.panel || this._ignoreDismissal) + return; + + let browser = this.panel.firstChild && + this.panel.firstChild.notification.browser; + if (!browser) + return; + + let notifications = this._getNotificationsForBrowser(browser); + // Mark notifications as dismissed and call dismissal callbacks + Array.forEach(this.panel.childNodes, function (nEl) { + let notificationObj = nEl.notification; + // Never call a dismissal handler on a notification that's been removed. + if (notifications.indexOf(notificationObj) == -1) + return; + + // Do not mark the notification as dismissed or fire NOTIFICATION_EVENT_DISMISSED + // if the notification is removed. + if (notificationObj.options.removeOnDismissal) + this._remove(notificationObj); + else { + notificationObj.dismissed = true; + this._fireCallback(notificationObj, NOTIFICATION_EVENT_DISMISSED); + } + }, this); + + this._clearPanel(); + + this._update(); + }, + + _onButtonCommand: function PopupNotifications_onButtonCommand(event) { + let notificationEl = getNotificationFromElement(event.originalTarget); + + if (!notificationEl) + throw "PopupNotifications_onButtonCommand: couldn't find notification element"; + + if (!notificationEl.notification) + throw "PopupNotifications_onButtonCommand: couldn't find notification"; + + let notification = notificationEl.notification; + let timeSinceShown = this.window.performance.now() - notification.timeShown; + + // Only report the first time mainAction is triggered and remember that this occurred. + if (!notification.timeMainActionFirstTriggered) { + notification.timeMainActionFirstTriggered = timeSinceShown; + } + + if (timeSinceShown < this.buttonDelay) { + Services.console.logStringMessage("PopupNotifications_onButtonCommand: " + + "Button click happened before the security delay: " + + timeSinceShown + "ms"); + return; + } + + try { + notification.mainAction.callback.call(undefined, { + checkboxChecked: notificationEl.checkbox.checked + }); + } catch (error) { + Cu.reportError(error); + } + + this._remove(notification); + this._update(); + }, + + _onMenuCommand: function PopupNotifications_onMenuCommand(event) { + let target = event.originalTarget; + if (!target.action || !target.notification) + throw "menucommand target has no associated action/notification"; + + let notificationEl = target.parentElement; + event.stopPropagation(); + + try { + target.action.callback.call(undefined, { + checkboxChecked: notificationEl.checkbox.checked + }); + } catch (error) { + Cu.reportError(error); + } + + this._remove(target.notification); + this._update(); + }, + + _notify: function PopupNotifications_notify(topic) { + Services.obs.notifyObservers(null, "PopupNotifications-" + topic, ""); + }, +}; diff --git a/modules/QuotaManager.jsm b/modules/QuotaManager.jsm new file mode 100644 index 0000000..48cfe88 --- /dev/null +++ b/modules/QuotaManager.jsm @@ -0,0 +1,51 @@ +/* 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 = ["QuotaManagerHelper"]; + +Components.utils.import('resource://gre/modules/Services.jsm'); + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; + +this.QuotaManagerHelper = { + clear: function(isShutDown) { + try { + var stord = Services.dirsvc.get("ProfD", Ci.nsIFile); + stord.append("storage"); + if (stord.exists() && stord.isDirectory()) { + var doms = {}; + for (var stor of ["default", "permanent", "temporary"]) { + var storsubd = stord.clone(); + storsubd.append(stor); + if (storsubd.exists() && storsubd.isDirectory()) { + var entries = storsubd.directoryEntries; + while(entries.hasMoreElements()) { + var host, entry = entries.getNext(); + entry.QueryInterface(Ci.nsIFile); + if ((host = /^(https?|file)\+\+\+(.+)$/.exec(entry.leafName)) !== null) { + if (isShutDown) { + entry.remove(true); + } else { + doms[host[1] + "://" + host[2]] = true; + } + } + } + } + } + var qm = Cc["@mozilla.org/dom/quota-manager-service;1"] + .getService(Ci.nsIQuotaManagerService); + for (var dom in doms) { + var uri = Services.io.newURI(dom, null, null); + let principal = Services.scriptSecurityManager + .createCodebasePrincipal(uri, {}); + qm.clearStoragesForPrincipal(principal); + } + } + } catch(er) { + Cu.reportError(er); + } + } +}; diff --git a/modules/RecentWindow.jsm b/modules/RecentWindow.jsm new file mode 100644 index 0000000..0018b50 --- /dev/null +++ b/modules/RecentWindow.jsm @@ -0,0 +1,68 @@ +/* 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/Services.jsm"); +Cu.import("resource://gre/modules/PrivateBrowsingUtils.jsm"); + +#ifndef XP_WIN +#define BROKEN_WM_Z_ORDER +#endif + +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)); + } + +#ifdef 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; +#else + let windowList = Services.wm.getZOrderDOMWindowEnumerator("navigator:browser", true); + while (windowList.hasMoreElements()) { + let win = windowList.getNext(); + if (isSuitableBrowserWindow(win)) + return win; + } + return null; +#endif + } +}; + diff --git a/modules/SharedFrame.jsm b/modules/SharedFrame.jsm new file mode 100644 index 0000000..b9d59bf --- /dev/null +++ b/modules/SharedFrame.jsm @@ -0,0 +1,221 @@ +/* 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 = [ "SharedFrame" ]; + +const Ci = Components.interfaces; +const Cu = Components.utils; + +/** + * The purpose of this module is to create and group various iframe + * elements that are meant to all display the same content and only + * one at a time. This makes it possible to have the content loaded + * only once, while the other iframes can be kept as placeholders to + * quickly move the content to them through the swapFrameLoaders function + * when another one of the placeholder is meant to be displayed. + * */ + +var Frames = new Map(); + +/** + * The Frames map is the main data structure that holds information + * about the groups being tracked. Each entry's key is the group name, + * and the object holds information about what is the URL being displayed + * on that group, and what is the active element on the group (the frame that + * holds the loaded content). + * The reference to the activeFrame is a weak reference, which allows the + * frame to go away at any time, and when that happens the module considers that + * there are no active elements in that group. The group can be reactivated + * by changing the URL, calling preload again or adding a new element. + * + * + * Frames = { + * "messages-panel": { + * url: string, + * activeFrame: weakref + * } + * } + * + * Each object on the map is called a _SharedFrameGroup, which is an internal + * class of this module which does not automatically keep track of its state. This + * object should not be used externally, and all control should be handled by the + * module's functions. + */ + +function UNLOADED_URL(aStr) "data:text/html;charset=utf-8,"; + + +this.SharedFrame = { + /** + * Creates an iframe element and track it as part of the specified group + * The module must create the iframe itself because it needs to do some special + * handling for the element's src attribute. + * + * @param aGroupName the name of the group to which this frame belongs + * @param aParent the parent element to which the frame will be appended to + * @param aFrameAttributes an object with a list of attributes to set in the iframe + * before appending it to the DOM. The "src" attribute has + * special meaning here and if it's not blank it specifies + * the URL that will be initially assigned to this group + * @param aPreload optional, tells if the URL specified in the src attribute + * should be preloaded in the frame being created, in case + * it's not yet preloaded in any other frame of the group. + * This parameter has no meaning if src is blank. + */ + createFrame: function (aGroupName, aParent, aFrameAttributes, aPreload = true) { + let frame = aParent.ownerDocument.createElement("iframe"); + + for (let [key, val] of Iterator(aFrameAttributes)) { + frame.setAttribute(key, val); + } + + let src = aFrameAttributes.src; + if (!src) { + aPreload = false; + } + + let group = Frames.get(aGroupName); + + if (group) { + // If this group has already been created + + if (aPreload && !group.isAlive) { + // If aPreload is set and the group is not already loaded, load it. + // This can happen if: + // - aPreload was not used while creating the previous frames of this group, or + // - the previously active frame went dead in the meantime + group.url = src; + this.preload(aGroupName, frame); + } else { + // If aPreload is not set, or the group is already loaded in a different frame, + // there's not much that we need to do here: just create this frame as an + // inactivate placeholder + frame.setAttribute("src", UNLOADED_URL(aGroupName)); + } + + } else { + // This is the first time we hear about this group, so let's start tracking it, + // and also preload it if the src attribute was set and aPreload = true + group = new _SharedFrameGroup(src); + Frames.set(aGroupName, group); + + if (aPreload) { + this.preload(aGroupName, frame); + } else { + frame.setAttribute("src", UNLOADED_URL(aGroupName)); + } + } + + aParent.appendChild(frame); + return frame; + + }, + + /** + * Function that moves the loaded content from one active frame to + * another one that is currently a placeholder. If there's no active + * frame in the group, the content is loaded/reloaded. + * + * @param aGroupName the name of the group + * @param aTargetFrame the frame element to which the content should + * be moved to. + */ + setOwner: function (aGroupName, aTargetFrame) { + let group = Frames.get(aGroupName); + let frame = group.activeFrame; + + if (frame == aTargetFrame) { + // nothing to do here + return; + } + + if (group.isAlive) { + // Move document ownership to the desired frame, and make it the active one + frame.QueryInterface(Ci.nsIFrameLoaderOwner).swapFrameLoaders(aTargetFrame); + group.activeFrame = aTargetFrame; + } else { + // Previous owner was dead, reload the document at the new owner and make it the active one + aTargetFrame.setAttribute("src", group.url); + group.activeFrame = aTargetFrame; + } + }, + + /** + * Updates the current URL in use by this group, and loads it into the active frame. + * + * @param aGroupName the name of the group + * @param aURL the new url + */ + updateURL: function (aGroupName, aURL) { + let group = Frames.get(aGroupName); + group.url = aURL; + + if (group.isAlive) { + group.activeFrame.setAttribute("src", aURL); + } + }, + + /** + * Loads the group's url into a target frame, if the group doesn't have a currently + * active frame. + * + * @param aGroupName the name of the group + * @param aTargetFrame the frame element which should be made active and + * have the group's content loaded to + */ + preload: function (aGroupName, aTargetFrame) { + let group = Frames.get(aGroupName); + if (!group.isAlive) { + aTargetFrame.setAttribute("src", group.url); + group.activeFrame = aTargetFrame; + } + }, + + /** + * Tells if a group currently have an active element. + * + * @param aGroupName the name of the group + */ + isGroupAlive: function (aGroupName) { + return Frames.get(aGroupName).isAlive; + }, + + /** + * Forgets about this group. This function doesn't need to be used + * unless the group's name needs to be reused. + * + * @param aGroupName the name of the group + */ + forgetGroup: function (aGroupName) { + Frames.delete(aGroupName); + } +} + + +function _SharedFrameGroup(aURL) { + this.url = aURL; + this._activeFrame = null; +} + +_SharedFrameGroup.prototype = { + get isAlive() { + let frame = this.activeFrame; + return !!(frame && + frame.contentDocument && + frame.contentDocument.location); + }, + + get activeFrame() { + return this._activeFrame && + this._activeFrame.get(); + }, + + set activeFrame(aActiveFrame) { + this._activeFrame = aActiveFrame + ? Cu.getWeakReference(aActiveFrame) + : null; + } +} diff --git a/modules/Windows8WindowFrameColor.jsm b/modules/Windows8WindowFrameColor.jsm new file mode 100644 index 0000000..e7a447d --- /dev/null +++ b/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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +this.EXPORTED_SYMBOLS = ["Windows8WindowFrameColor"]; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/WindowsRegistry.jsm"); + +var Windows8WindowFrameColor = { + _windowFrameColor: null, + + get_win8: function() { + if (this._windowFrameColor) + return this._windowFrameColor; + + const HKCU = Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER; + const dwmKey = "Software\\Microsoft\\Windows\\DWM"; + + // Window frame base color component values when Color Intensity is at 0. + let frameBaseColor = 217; + + let windowFrameColor = WindowsRegistry.readRegKey(HKCU, dwmKey, + "ColorizationColor"); + if (windowFrameColor == undefined) { + // Return the default color if unset or colorization not used + return this._windowFrameColor = [frameBaseColor, frameBaseColor, frameBaseColor]; + } + // The color returned from the Registry is in decimal form. + let windowFrameColorHex = windowFrameColor.toString(16); + // Zero-pad the number just to make sure that it is 8 digits. + windowFrameColorHex = ("00000000" + windowFrameColorHex).substr(-8); + let windowFrameColorArray = windowFrameColorHex.match(/../g); + let [unused, fgR, fgG, fgB] = windowFrameColorArray.map(function(val) parseInt(val, 16)); + let windowFrameColorBalance = WindowsRegistry.readRegKey(HKCU, dwmKey, + "ColorizationColorBalance"); + // Default to balance=78 if reg key isn't defined + if (windowFrameColorBalance == undefined) { + windowFrameColorBalance = 78; + } + let alpha = windowFrameColorBalance / 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/modules/WindowsJumpLists.jsm b/modules/WindowsJumpLists.jsm new file mode 100644 index 0000000..e7f7855 --- /dev/null +++ b/modules/WindowsJumpLists.jsm @@ -0,0 +1,581 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 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() _getString("taskbar.tasks.newTab.label"), + get description() _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() _getString("taskbar.tasks.newWindow.label"), + get description() _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() _getString("taskbar.tasks.newPrivateWindow.label"), + get description() _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 "back": + this._updateTimer(); + break; + } + }, +}; diff --git a/modules/WindowsPreviewPerTab.jsm b/modules/WindowsPreviewPerTab.jsm new file mode 100644 index 0000000..4b5030a --- /dev/null +++ b/modules/WindowsPreviewPerTab.jsm @@ -0,0 +1,861 @@ +/* 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. The taskbar + * allows an application to expose its tabbed interface 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 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) Listening for DOM events that result in a thumbnail or window preview needing + * to be refreshed, and communicating this to the taskbar. + * 3) Handling queryies and returning new thumbnail or window preview images to the + * taskbar through PageThumbs. + * + * @param win + * The TabWindow (see below) that owns the preview that this controls + * @param tab + * The 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 self = this; + 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(self.win), + img => { + let index = self.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 = self.tabbrowser.tabs[index]; + let preview = self.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/modules/moz.build b/modules/moz.build new file mode 100644 index 0000000..12a3ece --- /dev/null +++ b/modules/moz.build @@ -0,0 +1,42 @@ +# -*- Mode: python; c-basic-offset: 4; 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/. + +EXTRA_JS_MODULES += [ + 'AutoCompletePopup.jsm', + 'BrowserNewTabPreloader.jsm', + 'CharsetMenu.jsm', + 'FormSubmitObserver.jsm', + 'FormValidationHandler.jsm', + 'NetworkPrioritizer.jsm', + 'offlineAppCache.jsm', + 'openLocationLastURL.jsm', + 'PageMenu.jsm', + 'PopupNotifications.jsm', + 'QuotaManager.jsm', + 'SharedFrame.jsm' +] + +if CONFIG['MOZ_WEBRTC']: + EXTRA_JS_MODULES += ['webrtcUI.jsm'] + +if CONFIG['MOZ_WIDGET_TOOLKIT'] == 'windows': + EXTRA_JS_MODULES += [ + 'Windows8WindowFrameColor.jsm', + 'WindowsJumpLists.jsm', + 'WindowsPreviewPerTab.jsm', + ] + +EXTRA_PP_JS_MODULES += [ + 'AboutHomeUtils.jsm', + 'RecentWindow.jsm', +] + +# Pass down 'official build' flags +if CONFIG['MC_OFFICIAL']: + DEFINES['MC_OFFICIAL'] = 1 + +if CONFIG['MOZILLA_OFFICIAL']: + DEFINES['MOZILLA_OFFICIAL'] = 1 diff --git a/modules/offlineAppCache.jsm b/modules/offlineAppCache.jsm new file mode 100644 index 0000000..00ded09 --- /dev/null +++ b/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/modules/openLocationLastURL.jsm b/modules/openLocationLastURL.jsm new file mode 100644 index 0000000..3f58db8 --- /dev/null +++ b/modules/openLocationLastURL.jsm @@ -0,0 +1,85 @@ +/* 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 LAST_URL_PREF = "general.open_location.last_url"; +const nsISupportsString = Components.interfaces.nsISupportsString; +const Ci = Components.interfaces; + +Components.utils.import("resource://gre/modules/PrivateBrowsingUtils.jsm"); + +this.EXPORTED_SYMBOLS = [ "OpenLocationLastURL" ]; + +var prefSvc = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefBranch); +var gOpenLocationLastURLData = ""; + +var observer = { + QueryInterface: function (aIID) { + if (aIID.equals(Components.interfaces.nsIObserver) || + aIID.equals(Components.interfaces.nsISupports) || + aIID.equals(Components.interfaces.nsISupportsWeakReference)) + return this; + throw Components.results.NS_NOINTERFACE; + }, + observe: function (aSubject, aTopic, aData) { + switch (aTopic) { + case "last-pb-context-exited": + gOpenLocationLastURLData = ""; + break; + case "browser:purge-session-history": + prefSvc.clearUserPref(LAST_URL_PREF); + gOpenLocationLastURLData = ""; + break; + } + } +}; + +var os = Components.classes["@mozilla.org/observer-service;1"] + .getService(Components.interfaces.nsIObserverService); +os.addObserver(observer, "last-pb-context-exited", true); +os.addObserver(observer, "browser:purge-session-history", true); + + +this.OpenLocationLastURL = function OpenLocationLastURL(aWindow) { + this.window = aWindow; +} + +OpenLocationLastURL.prototype = { + isPrivate: function OpenLocationLastURL_isPrivate() { + // Assume not in private browsing mode, unless the browser window is + // in private mode. + if (!this.window) + return false; + + return PrivateBrowsingUtils.isWindowPrivate(this.window); + }, + get value() { + if (this.isPrivate()) + return gOpenLocationLastURLData; + else { + try { + return prefSvc.getComplexValue(LAST_URL_PREF, nsISupportsString).data; + } + catch (e) { + return ""; + } + } + }, + set value(val) { + if (typeof val != "string") + val = ""; + if (this.isPrivate()) + gOpenLocationLastURLData = val; + else { + let str = Components.classes["@mozilla.org/supports-string;1"] + .createInstance(Components.interfaces.nsISupportsString); + str.data = val; + prefSvc.setComplexValue(LAST_URL_PREF, nsISupportsString, str); + } + }, + reset: function() { + prefSvc.clearUserPref(LAST_URL_PREF); + gOpenLocationLastURLData = ""; + } +}; diff --git a/modules/webrtcUI.jsm b/modules/webrtcUI.jsm new file mode 100644 index 0000000..819ca18 --- /dev/null +++ b/modules/webrtcUI.jsm @@ -0,0 +1,292 @@ +/* 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, "PluralForm", + "resource://gre/modules/PluralForm.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "MediaManagerService", + "@mozilla.org/mediaManagerService;1", + "nsIMediaManagerService"); + +this.webrtcUI = { + init: function () { + Services.obs.addObserver(handleRequest, "getUserMedia:request", false); + Services.obs.addObserver(updateIndicators, "recording-device-events", false); + Services.obs.addObserver(removeBrowserSpecificIndicator, "recording-window-ended", false); + }, + + uninit: function () { + Services.obs.removeObserver(handleRequest, "getUserMedia:request"); + Services.obs.removeObserver(updateIndicators, "recording-device-events"); + Services.obs.removeObserver(removeBrowserSpecificIndicator, "recording-window-ended"); + }, + + showGlobalIndicator: false, + + get activeStreams() { + let contentWindowSupportsArray = MediaManagerService.activeMediaCaptureWindows; + let count = contentWindowSupportsArray.Count(); + let activeStreams = []; + for (let i = 0; i < count; i++) { + let contentWindow = contentWindowSupportsArray.GetElementAt(i); + let browser = getBrowserForWindow(contentWindow); + let browserWindow = browser.ownerDocument.defaultView; + let tab = browserWindow.gBrowser && + browserWindow.gBrowser._getTabForContentWindow(contentWindow.top); + activeStreams.push({ + uri: contentWindow.location.href, + tab: tab, + browser: browser + }); + } + return activeStreams; + } +} + +function getBrowserForWindowId(aWindowID) { + return getBrowserForWindow(Services.wm.getOuterWindowWithId(aWindowID)); +} + +function getBrowserForWindow(aContentWindow) { + return aContentWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell) + .chromeEventHandler; +} + +function handleRequest(aSubject, aTopic, aData) { + let {windowID: windowID, callID: callID} = JSON.parse(aData); + + let params = aSubject.QueryInterface(Ci.nsIMediaStreamOptions); + + Services.wm.getMostRecentWindow(null).navigator.mozGetUserMediaDevices( + function (devices) { + prompt(windowID, callID, params.audio, params.video || params.picture, devices); + }, + function (error) { + // bug 827146 -- In the future, the UI should catch NO_DEVICES_FOUND + // and allow the user to plug in a device, instead of immediately failing. + denyRequest(callID, error); + } + ); +} + +function denyRequest(aCallID, 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", aCallID); +} + +function prompt(aWindowID, aCallID, aAudioRequested, aVideoRequested, aDevices) { + let audioDevices = []; + let videoDevices = []; + for (let device of aDevices) { + device = device.QueryInterface(Ci.nsIMediaDevice); + switch (device.type) { + case "audio": + if (aAudioRequested) + audioDevices.push(device); + break; + case "video": + if (aVideoRequested) + videoDevices.push(device); + break; + } + } + + let requestType; + if (audioDevices.length && videoDevices.length) + requestType = "CameraAndMicrophone"; + else if (audioDevices.length) + requestType = "Microphone"; + else if (videoDevices.length) + requestType = "Camera"; + else { + denyRequest(aCallID, "NO_DEVICES_FOUND"); + return; + } + + let contentWindow = Services.wm.getOuterWindowWithId(aWindowID); + let host = contentWindow.document.documentURIObject.host; + let browser = getBrowserForWindow(contentWindow); + let chromeDoc = browser.ownerDocument; + let chromeWin = chromeDoc.defaultView; + let stringBundle = chromeWin.gNavigatorBundle; + let message = stringBundle.getFormattedString("getUserMedia.share" + requestType + ".message", + [ host ]); + + let mainAction = { + label: PluralForm.get(requestType == "CameraAndMicrophone" ? 2 : 1, + stringBundle.getString("getUserMedia.shareSelectedDevices.label")), + 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(aCallID); + } + }]; + + let options = { + eventCallback: function(aTopic, aNewBrowser) { + if (aTopic == "swapping") + return true; + + if (aTopic != "showing") + return false; + + let chromeDoc = this.browser.ownerDocument; + + function listDevices(menupopup, devices) { + while (menupopup.lastChild) + menupopup.removeChild(menupopup.lastChild); + + let deviceIndex = 0; + for (let device of devices) { + addDeviceToList(menupopup, device.name, deviceIndex); + deviceIndex++; + } + } + + function addDeviceToList(menupopup, deviceName, deviceIndex) { + let menuitem = chromeDoc.createElement("menuitem"); + menuitem.setAttribute("value", deviceIndex); + menuitem.setAttribute("label", deviceName); + menuitem.setAttribute("tooltiptext", deviceName); + menupopup.appendChild(menuitem); + } + + chromeDoc.getElementById("webRTC-selectCamera").hidden = !videoDevices.length; + chromeDoc.getElementById("webRTC-selectMicrophone").hidden = !audioDevices.length; + + let camMenupopup = chromeDoc.getElementById("webRTC-selectCamera-menupopup"); + let micMenupopup = chromeDoc.getElementById("webRTC-selectMicrophone-menupopup"); + listDevices(camMenupopup, videoDevices); + listDevices(micMenupopup, audioDevices); + if (requestType == "CameraAndMicrophone") { + let stringBundle = chromeDoc.defaultView.gNavigatorBundle; + addDeviceToList(camMenupopup, stringBundle.getString("getUserMedia.noVideo.label"), "-1"); + addDeviceToList(micMenupopup, stringBundle.getString("getUserMedia.noAudio.label"), "-1"); + } + + this.mainAction.callback = function() { + let allowedDevices = Cc["@mozilla.org/supports-array;1"] + .createInstance(Ci.nsISupportsArray); + if (videoDevices.length) { + let videoDeviceIndex = chromeDoc.getElementById("webRTC-selectCamera-menulist").value; + if (videoDeviceIndex != "-1") + allowedDevices.AppendElement(videoDevices[videoDeviceIndex]); + } + if (audioDevices.length) { + let audioDeviceIndex = chromeDoc.getElementById("webRTC-selectMicrophone-menulist").value; + if (audioDeviceIndex != "-1") + allowedDevices.AppendElement(audioDevices[audioDeviceIndex]); + } + + if (allowedDevices.Count() == 0) { + denyRequest(aCallID); + return; + } + + Services.obs.notifyObservers(allowedDevices, "getUserMedia:response:allow", aCallID); + }; + return true; + } + }; + + chromeWin.PopupNotifications.show(browser, "webRTC-shareDevices", message, + "webRTC-shareDevices-notification-icon", mainAction, + secondaryActions, options); +} + +function updateIndicators() { + webrtcUI.showGlobalIndicator = + MediaManagerService.activeMediaCaptureWindows.Count() > 0; + + let e = Services.wm.getEnumerator("navigator:browser"); + while (e.hasMoreElements()) + e.getNext().WebrtcIndicator.updateButton(); + + for (let {browser: browser} of webrtcUI.activeStreams) + showBrowserSpecificIndicator(browser); +} + +function showBrowserSpecificIndicator(aBrowser) { + let hasVideo = {}; + let hasAudio = {}; + MediaManagerService.mediaCaptureWindowState(aBrowser.contentWindow, + hasVideo, hasAudio); + let captureState; + if (hasVideo.value && hasAudio.value) { + captureState = "CameraAndMicrophone"; + } else if (hasVideo.value) { + captureState = "Camera"; + } else if (hasAudio.value) { + captureState = "Microphone"; + } else { + Cu.reportError("showBrowserSpecificIndicator: got neither video nor audio access"); + return; + } + + let chromeWin = aBrowser.ownerDocument.defaultView; + let stringBundle = chromeWin.gNavigatorBundle; + + let message = stringBundle.getString("getUserMedia.sharing" + captureState + ".message2"); + + let windowId = aBrowser.contentWindow + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .currentInnerWindowID; + let mainAction = { + label: "Continue Sharing", //stringBundle.getString("getUserMedia.continueSharing.label"), + accessKey: "C", //stringBundle.getString("getUserMedia.continueSharing.accesskey"), + callback: function () {}, + dismiss: true + }; + let secondaryActions = [{ + label: "Stop Sharing", //stringBundle.getString("getUserMedia.stopSharing.label"), + accessKey: "S", //stringBundle.getString("getUserMedia.stopSharing.accesskey"), + callback: function () { + Services.obs.notifyObservers(null, "getUserMedia:revoke", windowId); + } + }]; + let options = { + hideNotNow: true, + dismissed: true, + eventCallback: function(aTopic) aTopic == "swapping" + }; + chromeWin.PopupNotifications.show(aBrowser, "webRTC-sharingDevices", message, + "webRTC-sharingDevices-notification-icon", mainAction, + secondaryActions, options); +} + +function removeBrowserSpecificIndicator(aSubject, aTopic, aData) { + let browser = getBrowserForWindowId(aData); + let PopupNotifications = browser.ownerDocument.defaultView.PopupNotifications; + let notification = PopupNotifications && + PopupNotifications.getNotification("webRTC-sharingDevices", + browser); + if (notification) + PopupNotifications.remove(notification); +} -- cgit v1.2.3