diff --git a/browser/base/content/remote-newtab/newTab.js b/browser/base/content/remote-newtab/newTab.js index 920830c96767..d487dbbf8caa 100644 --- a/browser/base/content/remote-newtab/newTab.js +++ b/browser/base/content/remote-newtab/newTab.js @@ -44,12 +44,15 @@ XPCOMUtils.defineLazyModuleGetter(this, "Services", registerEvent(data.type); break; case "NewTab:GetInitialState": - getInitialState(); - break; + data = {}; + data.windowID = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils).outerWindowID; + data.privateBrowsingMode = PrivateBrowsingUtils.isContentWindowPrivate(window); + // Fallthrough - more handling required. default: commandHandled = false; } - return commandHandled; + return {commandHandled, data}; } function initRemotePage(initData) { @@ -68,9 +71,9 @@ XPCOMUtils.defineLazyModuleGetter(this, "Services", remoteIFrame.contentDocument.addEventListener("NewTabCommand", (e) => { // If the commands are not handled within this content frame, the command will be // passed on to main process, in RemoteAboutNewTab.jsm - let handled = handleCommand(e.detail.command, e.detail.data); + let {handled, data} = handleCommand(e.detail.command, e.detail.data); if (!handled) { - sendAsyncMessage(e.detail.command, e.detail.data); + sendAsyncMessage(e.detail.command, data); } }); registerEvent("NewTab:Observe"); @@ -95,28 +98,6 @@ XPCOMUtils.defineLazyModuleGetter(this, "Services", }); } - /** - * Sends the initial data payload to a content IFrame so it can bootstrap - */ - function getInitialState() { - let prefs = Services.prefs; - let isPrivate = PrivateBrowsingUtils.isContentWindowPrivate(window); - let state = { - enabled: prefs.getBoolPref("browser.newtabpage.enabled"), - enhanced: prefs.getBoolPref("browser.newtabpage.enhanced"), - rows: prefs.getIntPref("browser.newtabpage.rows"), - columns: prefs.getIntPref("browser.newtabpage.columns"), - introShown: prefs.getBoolPref("browser.newtabpage.introShown"), - windowID: window.QueryInterface(Ci.nsIInterfaceRequestor) - .getInterface(Ci.nsIDOMWindowUtils).outerWindowID, - privateBrowsingMode: isPrivate - }; - remoteIFrame.contentWindow.postMessage({ - name: "NewTab:State", - data: state - }, remoteNewTabLocation.origin); - } - addMessageListener("NewTabFrame:Init", function loadHandler(message) { // Everything is loaded. Initialize the New Tab Page. removeMessageListener("NewTabFrame:Init", loadHandler); diff --git a/browser/components/newtab/NewTabPrefsProvider.jsm b/browser/components/newtab/NewTabPrefsProvider.jsm new file mode 100644 index 000000000000..202da7ae8990 --- /dev/null +++ b/browser/components/newtab/NewTabPrefsProvider.jsm @@ -0,0 +1,65 @@ +/* global Services, EventEmitter, XPCOMUtils */ +/* exported NewTabPrefsProvider */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["NewTabPrefsProvider"]; + +const Cu = Components.utils; +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "EventEmitter", function() { + const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {}); + return EventEmitter; +}); + +const prefsSet = new Set([ + "browser.newtabpage.enabled", + "browser.newtabpage.enhanced", + "browser.newtabpage.pinned", + "intl.locale.matchOS", + "general.useragent.locale", +]); + +let PrefsProvider = function PrefsProvider() { + EventEmitter.decorate(this); +}; + +PrefsProvider.prototype = { + + observe(subject, topic, data) { // jshint ignore:line + if (topic === "nsPref:changed") { + if (prefsSet.has(data)) { + this.emit(data); + } + } else { + Cu.reportError(new Error("NewTabPrefsProvider observing unknown topic")); + } + }, + + get prefs() { + return Array.from(prefsSet); + }, + + startTracking() { + for (let pref of prefsSet) { + Services.prefs.addObserver(pref, this, false); + } + }, + + stopTracking() { + for (let pref of prefsSet) { + Services.prefs.removeObserver(pref, this, false); + } + } +}; + +/** + * Singleton that serves as the default new tab pref provider for the grid. + */ +const gPrefs = new PrefsProvider(); + +let NewTabPrefsProvider = { + prefs: gPrefs, +}; diff --git a/browser/components/newtab/RemoteAboutNewTab.jsm b/browser/components/newtab/RemoteAboutNewTab.jsm index 722e0bae85d1..049dd4b25201 100644 --- a/browser/components/newtab/RemoteAboutNewTab.jsm +++ b/browser/components/newtab/RemoteAboutNewTab.jsm @@ -1,9 +1,9 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* globals Services, XPCOMUtils, RemotePages, SearchProvider, RemoteNewTabLocation, RemoteNewTabUtils, Task */ +/* globals BackgroundPageThumbs, PageThumbs, DirectoryLinksProvider, PlacesProvider */ -/* globals Services, XPCOMUtils, RemotePages, RemoteNewTabLocation, RemoteNewTabUtils, Task */ -/* globals BackgroundPageThumbs, PageThumbs, DirectoryLinksProvider */ /* exported RemoteAboutNewTab */ "use strict"; @@ -31,6 +31,10 @@ XPCOMUtils.defineLazyModuleGetter(this, "DirectoryLinksProvider", "resource:///modules/DirectoryLinksProvider.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "RemoteNewTabLocation", "resource:///modules/RemoteNewTabLocation.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SearchProvider", + "resource:///modules/SearchProvider.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesProvider", + "resource:///modules/PlacesProvider.jsm"); let RemoteAboutNewTab = { @@ -44,13 +48,98 @@ let RemoteAboutNewTab = { this.pageListener.addMessageListener("NewTab:InitializeGrid", this.initializeGrid.bind(this)); this.pageListener.addMessageListener("NewTab:UpdateGrid", this.updateGrid.bind(this)); this.pageListener.addMessageListener("NewTab:CaptureBackgroundPageThumbs", - this.captureBackgroundPageThumb.bind(this)); + this.captureBackgroundPageThumb.bind(this)); this.pageListener.addMessageListener("NewTab:PageThumbs", this.createPageThumb.bind(this)); + this.pageListener.addMessageListener("NewTab:Search", this.search.bind(this)); + this.pageListener.addMessageListener("NewTab:GetState", this.getState.bind(this)); + this.pageListener.addMessageListener("NewTab:GetStrings", this.getStrings.bind(this)); + this.pageListener.addMessageListener("NewTab:GetSuggestions", this.getSuggestions.bind(this)); + this.pageListener.addMessageListener("NewTab:RemoveFormHistoryEntry", this.removeFormHistoryEntry.bind(this)); + this.pageListener.addMessageListener("NewTab:ManageEngines", this.manageEngines.bind(this)); + this.pageListener.addMessageListener("NewTab:SetCurrentEngine", this.setCurrentEngine.bind(this)); this.pageListener.addMessageListener("NewTabFrame:GetInit", this.initContentFrame.bind(this)); + this.pageListener.addMessageListener("NewTab:GetInitialState", this.getInitialState.bind(this)); this._addObservers(); }, + search: function(message) { + SearchProvider.performSearch(message.target.browser, message.data); + }, + + getState: Task.async(function* (message) { + let state = yield SearchProvider.state; + message.target.sendAsyncMessage("NewTab:ContentSearchService", { + state, + name: "State", + }); + }), + + getStrings: function(message) { + let strings = SearchProvider.searchSuggestionUIStrings; + message.target.sendAsyncMessage("NewTab:ContentSearchService", { + strings, + name: "Strings", + }); + }, + + getSuggestions: Task.async(function* (message) { + try { + let suggestion = yield SearchProvider.getSuggestions(message.target.browser, message.data); + + // In the case where there is no suggestion available, do not send a message. + if (suggestion !== null) { + message.target.sendAsyncMessage("NewTab:ContentSearchService", { + suggestion, + name: "Suggestions", + }); + } + } catch(e) { + Cu.reportError(e); + } + }), + + removeFormHistoryEntry: function(message) { + SearchProvider.removeFormHistoryEntry(message.target.browser, message.data.suggestionStr); + }, + + manageEngines: function(message) { + let browserWin = message.target.browser.ownerDocument.defaultView; + browserWin.openPreferences("paneSearch"); + }, + + setCurrentEngine: function(message) { + Services.search.currentEngine = Services.search.getEngineByName(message.data.engineName); + }, + + /** + * Notifies when history is cleared + */ + placesClearHistory: function() { + this.pageListener.sendAsyncMessage("NewTab:PlacesClearHistory"); + }, + + /** + * Notifies when a link has changed + */ + placesLinkChanged: function(link) { + this.pageListener.sendAsyncMessage("NewTab:PlacesLinkChanged", link); + }, + + /** + * Notifies when many links have changed + */ + placesManyLinksChanged: function() { + this.pageListener.sendAsyncMessage("NewTab:PlacesManyLinksChanged"); + }, + + /** + * Notifies when one URL has been deleted + */ + placesDeleteURI: function(data) { + this.pageListener.sendAsyncMessage("NewTab:PlacesDeleteURI", data); + }, + /** * Initializes the grid for the first time when the page loads. * Fetch all the links and send them down to the child to populate @@ -59,7 +148,7 @@ let RemoteAboutNewTab = { * @param {Object} message * A RemotePageManager message. */ - initializeGrid: function(message) { + initializeGrid(message) { RemoteNewTabUtils.links.populateCache(() => { message.target.sendAsyncMessage("NewTab:InitializeLinks", { links: RemoteNewTabUtils.links.getLinks(), @@ -78,6 +167,25 @@ let RemoteAboutNewTab = { }); }, + /** + * Sends the initial data payload to a content IFrame so it can bootstrap + */ + getInitialState: Task.async(function* (message) { + let placesLinks = yield PlacesProvider.links.getLinks(); + let prefs = Services.prefs; + let state = { + enabled: prefs.getBoolPref("browser.newtabpage.enabled"), + enhanced: prefs.getBoolPref("browser.newtabpage.enhanced"), + rows: prefs.getIntPref("browser.newtabpage.rows"), + columns: prefs.getIntPref("browser.newtabpage.columns"), + introShown: prefs.getBoolPref("browser.newtabpage.introShown"), + windowID: message.data.windowID, + privateBrowsingMode: message.data.privateBrowsingMode, + placesLinks + }; + message.target.sendAsyncMessage("NewTab:State", state); + }), + /** * Updates the grid by getting a new set of links. * @@ -147,7 +255,7 @@ let RemoteAboutNewTab = { let canvas = doc.createElementNS(XHTML_NAMESPACE, "canvas"); let enhanced = Services.prefs.getBoolPref("browser.newtabpage.enhanced"); - img.onload = function(e) { // jshint ignore:line + img.onload = function() { canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; var ctx = canvas.getContext("2d"); @@ -179,14 +287,14 @@ let RemoteAboutNewTab = { }, /** - * Listens for a preference change or session purge for all pages and sends - * a message to update the pages that are open. If a session purge occured, - * also clear the links cache and update the set of links to display, as they - * may have changed, then proceed with the page update. + * Listens for a preference change, a session purge for all pages, or if the + * current search engine is modified, and sends a message to update the pages + * that are open. If a session purge occured, also clear the links cache and + * update the set of links to display, as they may have changed, then proceed + * with the page update. */ observe: function(aSubject, aTopic, aData) { // jshint ignore:line let extraData; - let refreshPage = false; if (aTopic === "browser:purge-session-history") { RemoteNewTabUtils.links.resetCache(); RemoteNewTabUtils.links.populateCache(() => { @@ -195,6 +303,28 @@ let RemoteAboutNewTab = { enhancedLinks: this.getEnhancedLinks(), }); }); + } else if (aTopic === "browser-search-engine-modified" && aData === "engine-current") { + Task.spawn(function* () { + try { + let engine = yield SearchProvider.currentEngine; + this.pageListener.sendAsyncMessage("NewTab:ContentSearchService", { + engine, name: "CurrentEngine" + }); + } catch (e) { + Cu.reportError(e); + } + }.bind(this)); + } else if (aTopic === "nsPref:changed" && aData === "browser.search.hiddenOneOffs") { + Task.spawn(function* () { + try { + let state = yield SearchProvider.state; + this.pageListener.sendAsyncMessage("NewTab:ContentSearchService", { + state, name: "CurrentState" + }); + } catch (e) { + Cu.reportError(e); + } + }.bind(this)); } if (extraData !== undefined || aTopic === "page-thumbnail:create") { @@ -202,7 +332,10 @@ let RemoteAboutNewTab = { // Change the topic for enhanced and enabled observers. aTopic = aData; } - this.pageListener.sendAsyncMessage("NewTab:Observe", {topic: aTopic, data: extraData}); + this.pageListener.sendAsyncMessage("NewTab:Observe", { + topic: aTopic, + data: extraData + }); } }, @@ -212,6 +345,12 @@ let RemoteAboutNewTab = { _addObservers: function() { Services.obs.addObserver(this, "page-thumbnail:create", true); Services.obs.addObserver(this, "browser:purge-session-history", true); + Services.prefs.addObserver("browser.search.hiddenOneOffs", this, false); + Services.obs.addObserver(this, "browser-search-engine-modified", true); + PlacesProvider.links.on("deleteURI", this.placesDeleteURI.bind(this)); + PlacesProvider.links.on("clearHistory", this.placesClearHistory.bind(this)); + PlacesProvider.links.on("linkChanged", this.placesLinkChanged.bind(this)); + PlacesProvider.links.on("manyLinksChanged", this.placesManyLinksChanged.bind(this)); }, /** @@ -220,10 +359,17 @@ let RemoteAboutNewTab = { _removeObservers: function() { Services.obs.removeObserver(this, "page-thumbnail:create"); Services.obs.removeObserver(this, "browser:purge-session-history"); + Services.prefs.removeObserver("browser.search.hiddenOneOffs", this); + Services.obs.removeObserver(this, "browser-search-engine-modified"); + PlacesProvider.links.off("deleteURI", this.placesDeleteURI); + PlacesProvider.links.off("clearHistory", this.placesClearHistory); + PlacesProvider.links.off("linkChanged", this.placesLinkChanged); + PlacesProvider.links.off("manyLinksChanged", this.placesManyLinksChanged); }, QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, - Ci.nsISupportsWeakReference]), + Ci.nsISupportsWeakReference + ]), uninit: function() { this._removeObservers(); diff --git a/browser/components/newtab/RemoteNewTabLocation.jsm b/browser/components/newtab/RemoteNewTabLocation.jsm index 9ec171987721..afee841424ae 100644 --- a/browser/components/newtab/RemoteNewTabLocation.jsm +++ b/browser/components/newtab/RemoteNewTabLocation.jsm @@ -1,19 +1,47 @@ -/* globals Services */ +/* globals Services, UpdateUtils, XPCOMUtils, URL, NewTabPrefsProvider */ +/* exported RemoteNewTabLocation */ "use strict"; this.EXPORTED_SYMBOLS = ["RemoteNewTabLocation"]; -Components.utils.import("resource://gre/modules/Services.jsm"); -Components.utils.importGlobalProperties(["URL"]); +const {interfaces: Ci, utils: Cu} = Components; +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.importGlobalProperties(["URL"]); -// TODO: will get dynamically set in bug 1210478 -const DEFAULT_PAGE_LOCATION = "https://newtab.cdn.mozilla.net/v0/nightly/en-US/index.html"; +XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils", + "resource://gre/modules/UpdateUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabPrefsProvider", + "resource:///modules/NewTabPrefsProvider.jsm"); -this.RemoteNewTabLocation = { - _url: new URL(DEFAULT_PAGE_LOCATION), - _overridden: false, +// The preference that tells whether to match the OS locale +const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS"; +// The preference that tells what locale the user selected +const PREF_SELECTED_LOCALE = "general.useragent.locale"; + +const DEFAULT_PAGE_LOCATION = "https://newtab.cdn.mozilla.net/" + + "v%VERSION%/%CHANNEL%/%LOCALE%/index.html"; + +const VALID_CHANNELS = new Set(["esr", "release", "beta", "aurora", "nightly"]); + +const NEWTAB_VERSION = "0"; + +let RemoteLocationProvider = function() { + NewTabPrefsProvider.prefs.on( + PREF_SELECTED_LOCALE, + this._updateMaybe.bind(this)); + + NewTabPrefsProvider.prefs.on( + PREF_MATCH_OS_LOCALE, + this._updateMaybe.bind(this)); + + this._url = this._generateDefaultURL(); + this._overridden = false; +}; + +RemoteLocationProvider.prototype = { get href() { return this._url.href; }, @@ -26,17 +54,114 @@ this.RemoteNewTabLocation = { return this._overridden; }, - override: function(newURL) { - this._url = new URL(newURL); - this._overridden = true; - Services.obs.notifyObservers(null, "remote-new-tab-location-changed", - this._url.href); + /** + * Gets the currently selected locale + * + * @return {String} the selected locale or "en-US" if none is selected + */ + get locale() { + let result = "en-US"; + let matchOS; + + try { + matchOS = Services.prefs.getBoolPref(PREF_MATCH_OS_LOCALE); + } + catch (e) {} + + if (matchOS) { + result = Services.locale.getLocaleComponentForUserAgent(); + } + + try { + let locale = Services.prefs.getComplexValue(PREF_SELECTED_LOCALE, + Ci.nsIPrefLocalizedString); + if (locale) { + result = locale.data; + } + } + catch (e) {} + + try { + result = Services.prefs.getCharPref(PREF_SELECTED_LOCALE); + } + catch (e) {} + + return result; + }, + + get version() { + return NEWTAB_VERSION; + }, + + get channels() { + return VALID_CHANNELS; }, - reset: function() { - this._url = new URL(DEFAULT_PAGE_LOCATION); - this._overridden = false; - Services.obs.notifyObservers(null, "remote-new-tab-location-changed", - this._url.href); + /** + * Returns the release name from an Update Channel name + * + * @return {String} a release name based on the update channel. Defaults to nightly + */ + _releaseFromUpdateChannel(channel) { + let result = "nightly"; + if (VALID_CHANNELS.has(channel)) { + result = channel; + } + return result; + }, + + /* + * Updates the location when the page is not overriden. + * Useful when there is a pref change + */ + _updateMaybe() { + if (!this.overridden) { + let url = this._generateDefaultURL(); + if (url.href !== this._url.href) { + this._url = url; + Services.obs.notifyObservers(null, "remote-new-tab-location-changed", + this._url.href); + } + } + }, + + /* + * Generate a default url based on locale and update channel + */ + _generateDefaultURL() { + let releaseName = this._releaseFromUpdateChannel(UpdateUtils.UpdateChannel); + let uri = DEFAULT_PAGE_LOCATION + .replace("%VERSION%", this.version) + .replace("%LOCALE%", this.locale) + .replace("%CHANNEL%", releaseName); + return new URL(uri); + }, + + /* + * Override the Remote newtab page location. + */ + override(newURL) { + let url = new URL(newURL); + if (url.href !== this._url.href) { + this._overridden = true; + this._url = url; + Services.obs.notifyObservers(null, "remote-new-tab-location-changed", + this._url.href); + } + }, + + /* + * Reset the newtab page location to the default value + */ + reset() { + let url = this._generateDefaultURL(); + if (url.href !== this._url.href) { + this._url = url; + this._overridden = false; + Services.obs.notifyObservers(null, "remote-new-tab-location-changed", + this._url.href); + } } }; + +let RemoteNewTabLocation = new RemoteLocationProvider(); diff --git a/browser/components/newtab/SearchProvider.jsm b/browser/components/newtab/SearchProvider.jsm new file mode 100644 index 000000000000..3fac792fc491 --- /dev/null +++ b/browser/components/newtab/SearchProvider.jsm @@ -0,0 +1,303 @@ + /* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +/*globals Components, Services, XPCOMUtils, Task, SearchSuggestionController, PrivateBrowsingUtils, FormHistory*/ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ + "SearchProvider", +]; + +const { + classes: Cc, + interfaces: Ci, + utils: Cu, +} = Components; + +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.importGlobalProperties(["URL", "Blob"]); + +XPCOMUtils.defineLazyModuleGetter(this, "FormHistory", + "resource://gre/modules/FormHistory.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SearchSuggestionController", + "resource://gre/modules/SearchSuggestionController.jsm"); + +const stringBundle = Services.strings.createBundle("chrome://global/locale/autocomplete.properties"); +const searchBundle = Services.strings.createBundle("chrome://browser/locale/search.properties"); +const MAX_LOCAL_SUGGESTIONS = 3; +const MAX_SUGGESTIONS = 6; +// splits data urls into component parts [url, type, data] +const dataURLParts = /(?:^data:)(.+)(?:;base64,)(.*)$/; +const l10nKeysNames = [ + "searchHeader", + "searchPlaceholder", + "searchWithHeader", + "searchSettings", + "searchForSomethingWith", +]; + +this.SearchProvider = { + // This is used to handle search suggestions. It maps xul:browsers to objects + // { controller, previousFormHistoryResult }. See getSuggestions(). + _suggestionMap: new WeakMap(), + + _searchSuggestionUIStrings: new Map(), + + /** + * Makes a copy of the current search suggestions. + * + * @return {Object} Key/value pairs representing the search suggestions. + */ + get searchSuggestionUIStrings() { + let result = Object.create(null); + Array.from(this._searchSuggestionUIStrings.entries()) + .forEach(([key, value]) => result[key] = value); + return result; + }, + + /** + * Gets the state + * + * @return {Promise} Resolves to an object: + * engines {Object[]}: list of engines. + * currentEngine {Object}: the current engine. + */ + get state() { + return Task.spawn(function* () { + let state = { + engines: [], + currentEngine: yield this.currentEngine, + }; + let pref = Services.prefs.getCharPref("browser.search.hiddenOneOffs"); + let hiddenList = pref ? pref.split(",") : []; + for (let engine of Services.search.getVisibleEngines()) { + if (hiddenList.indexOf(engine.name) !== -1) { + continue; + } + let uri = engine.getIconURLBySize(16, 16); + state.engines.push({ + name: engine.name, + iconBuffer: yield this._arrayBufferFromDataURL(uri), + }); + } + return state; + }.bind(this)); + }, + + /** + * Get a browser to peform a search by opening a new window. + * + * @param {XULBrowser} browser The browser that performs the search. + * @param {Object} data The data used to perform the search. + * @return {Window} win The window that is performing the search. + */ + performSearch(browser, data) { + return Task.spawn(function* () { + let engine = Services.search.getEngineByName(data.engineName); + let submission = engine.getSubmission(data.searchString, "", data.searchPurpose); + // The browser may have been closed between the time its content sent the + // message and the time we handle it. In that case, trying to call any + // method on it will throw. + let win = browser.ownerDocument.defaultView; + + let where = win.whereToOpenLink(data.originalEvent); + + // There is a chance that by the time we receive the search message, the user + // has switched away from the tab that triggered the search. If, based on the + // event, we need to load the search in the same tab that triggered it (i.e. + // where == "current"), openUILinkIn will not work because that tab is no + // longer the current one. For this case we manually load the URI. + if (where === "current") { + browser.loadURIWithFlags(submission.uri.spec, + Ci.nsIWebNavigation.LOAD_FLAGS_NONE, null, null, + submission.postData); + } else { + let params = { + postData: submission.postData, + inBackground: Services.prefs.getBoolPref("browser.tabs.loadInBackground"), + }; + win.openUILinkIn(submission.uri.spec, where, params); + } + win.BrowserSearch.recordSearchInHealthReport(engine, data.healthReportKey, + data.selection || null); + + yield this.addFormHistoryEntry(browser, data.searchString); + return win; + }.bind(this)); + }, + + /** + * Returns the current search engine. + * + * @return {Object} An object the describes the current engine. + */ + get currentEngine() { + return Task.spawn(function* () { + let engine = Services.search.currentEngine; + let favicon = engine.getIconURLBySize(16, 16); + let uri1x = engine.getIconURLBySize(65, 26); + let uri2x = engine.getIconURLBySize(130, 52); + let placeholder = stringBundle.formatStringFromName( + "searchWithEngine", [engine.name], 1); + let obj = { + name: engine.name, + placeholder: placeholder, + iconBuffer: yield this._arrayBufferFromDataURL(favicon), + logoBuffer: yield this._arrayBufferFromDataURL(uri1x), + logo2xBuffer: yield this._arrayBufferFromDataURL(uri2x), + preconnectOrigin: new URL(engine.searchForm).origin, + }; + return obj; + }.bind(this)); + }, + + getSuggestions: function(browser, data) { + return Task.spawn(function* () { + let engine = Services.search.getEngineByName(data.engineName); + if (!engine) { + throw new Error(`Unknown engine name: ${data.engineName}`); + } + let { + controller + } = this._suggestionDataForBrowser(browser); + let ok = SearchSuggestionController.engineOffersSuggestions(engine); + controller.maxLocalResults = ok ? MAX_LOCAL_SUGGESTIONS : MAX_SUGGESTIONS; + controller.maxRemoteResults = ok ? MAX_SUGGESTIONS : 0; + controller.remoteTimeout = data.remoteTimeout || undefined; + let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(browser); + + let suggestions; + try { + // If fetch() rejects due to it's asynchronous behaviour, the suggestions + // is null and is then handled. + suggestions = yield controller.fetch(data.searchString, isPrivate, engine); + } catch (e) { + Cu.reportError(e); + } + + let result = null; + if (suggestions) { + this._suggestionMap.get(browser) + .previousFormHistoryResult = suggestions.formHistoryResult; + + result = { + engineName: data.engineName, + searchString: suggestions.term, + formHistory: suggestions.local, + remote: suggestions.remote, + }; + } + return result; + }.bind(this)); + }, + + addFormHistoryEntry: function(browser, entry = "") { + return Task.spawn(function* () { + let isPrivate = false; + try { + isPrivate = PrivateBrowsingUtils.isBrowserPrivate(browser); + } catch (err) { + // The browser might have already been destroyed. + return false; + } + if (isPrivate || entry === "") { + return false; + } + let { + controller + } = this._suggestionDataForBrowser(browser); + let result = yield new Promise((resolve, reject) => { + let ops = { + op: "bump", + fieldname: controller.formHistoryParam, + value: entry, + }; + let callbacks = { + handleCompletion: () => resolve(true), + handleError: reject, + }; + FormHistory.update(ops, callbacks); + }); + return result; + }.bind(this)); + }, + + /** + * Removes an entry from the form history for a given browser. + * + * @param {XULBrowser} browser the browser to delete from. + * @param {String} suggestion The suggestion to delete. + * @return {Boolean} True if removed, false otherwise. + */ + removeFormHistoryEntry(browser, suggestion) { + let { + previousFormHistoryResult + } = this._suggestionMap.get(browser); + if (!previousFormHistoryResult) { + return false; + } + for (let i = 0; i < previousFormHistoryResult.matchCount; i++) { + if (previousFormHistoryResult.getValueAt(i) === suggestion) { + previousFormHistoryResult.removeValueAt(i, true); + return true; + } + } + return false; + }, + + _suggestionDataForBrowser(browser) { + let data = this._suggestionMap.get(browser); + if (!data) { + // Since one SearchSuggestionController instance is meant to be used per + // autocomplete widget, this means that we assume each xul:browser has at + // most one such widget. + data = { + controller: new SearchSuggestionController(), + previousFormHistoryResult: undefined, + }; + this._suggestionMap.set(browser, data); + } + return data; + }, + + _arrayBufferFromDataURL(dataURL = "") { + if (!dataURL) { + return Promise.resolve(null); + } + return new Promise((resolve, reject) => { + try { + let fileReader = Cc["@mozilla.org/files/filereader;1"] + .createInstance(Ci.nsIDOMFileReader); + let [type, data] = dataURLParts.exec(dataURL).slice(1); + let bytes = atob(data); + let uInt8Array = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; ++i) { + uInt8Array[i] = bytes.charCodeAt(i); + } + let blob = new Blob([uInt8Array], { + type + }); + fileReader.onload = () => resolve(fileReader.result); + fileReader.onerror = () => resolve(null); + fileReader.readAsArrayBuffer(blob); + } catch (e) { + reject(e); + } + }); + }, + + init() { + // Perform localization + l10nKeysNames.map( + name => [name, searchBundle.GetStringFromName(name)] + ).forEach( + ([key, value]) => this._searchSuggestionUIStrings.set(key, value) + ); + } +}; +this.SearchProvider.init(); diff --git a/browser/components/newtab/moz.build b/browser/components/newtab/moz.build index a2660375a21b..e192437067ce 100644 --- a/browser/components/newtab/moz.build +++ b/browser/components/newtab/moz.build @@ -11,9 +11,11 @@ XPCSHELL_TESTS_MANIFESTS += [ ] EXTRA_JS_MODULES += [ + 'NewTabPrefsProvider.jsm', 'NewTabURL.jsm', 'PlacesProvider.jsm', 'RemoteAboutNewTab.jsm', 'RemoteNewTabLocation.jsm', 'RemoteNewTabUtils.jsm', + 'SearchProvider.jsm', ] diff --git a/browser/components/newtab/tests/browser/browser.ini b/browser/components/newtab/tests/browser/browser.ini index 8ec39550ccff..bbf1bc783a74 100644 --- a/browser/components/newtab/tests/browser/browser.ini +++ b/browser/components/newtab/tests/browser/browser.ini @@ -3,3 +3,4 @@ support-files = dummy_page.html [browser_remotenewtab_pageloads.js] +[browser_SearchProvider.js] diff --git a/browser/components/newtab/tests/browser/browser_SearchProvider.js b/browser/components/newtab/tests/browser/browser_SearchProvider.js new file mode 100644 index 000000000000..13a28d3e2bbb --- /dev/null +++ b/browser/components/newtab/tests/browser/browser_SearchProvider.js @@ -0,0 +1,163 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* globals ok, is, Services */ +"use strict"; +let imports = {}; +Components.utils.import("resource:///modules/SearchProvider.jsm", imports); + +// create test engine called MozSearch +Services.search.addEngineWithDetails("TestSearch", "", "", "", "GET", + "http://example.com/?q={searchTerms}"); +Services.search.defaultEngine = Services.search.getEngineByName("TestSearch"); + +function hasProp(obj) { + return function(aProp) { + ok(obj.hasOwnProperty(aProp), `expect to have property ${aProp}`); + }; +} + +add_task(function* testState() { + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: "about:newTab", + }, function* () { + + ok(imports.SearchProvider, "Search provider was created"); + + // state returns promise and eventually returns a state object + var state = yield imports.SearchProvider.state; + var stateProps = hasProp(state); + ["engines", "currentEngine"].forEach(stateProps); + + var { + engines + } = state; + + // engines should be an iterable + var proto = Object.getPrototypeOf(engines); + var isIterable = Object.getOwnPropertySymbols(proto)[0] === Symbol.iterator; + ok(isIterable, "Engines should be iterable."); + + // current engine should be the current engine from Services.search + var { + currentEngine + } = state; + is(currentEngine.name, Services.search.currentEngine.name, + "Current engine has been correctly set to default engine"); + + // current engine should properties + var engineProps = hasProp(currentEngine); + ["name", "placeholder", "iconBuffer", "logoBuffer", "logo2xBuffer", "preconnectOrigin"].forEach(engineProps); + + }); +}); + +add_task(function* testSearch() { + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: "about:newTab", + }, function* () { + + // perform a search + var searchData = { + engineName: Services.search.currentEngine.name, + searchString: "test", + healthReportKey: "newtab", + searchPurpose: "newtab", + originalEvent: { + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + button: false, + }, + }; + + // adding an entry to the form history will trigger a 'formhistory-add' notification, so we need to wait for + // this to resolve before checking that the search string has been added to the suggestions list + let addHistoryPromise = new Promise((resolve, reject) => { + Services.obs.addObserver(function onAdd(subject, topic, data) { // jshint ignore:line + if (data === "formhistory-add") { + Services.obs.removeObserver(onAdd, "satchel-storage-changed"); + resolve(data); + } else { + reject(); + } + }, "satchel-storage-changed", false); + }); + + // create promise before causing loadURI to prevent possible race conditions + let browserLoadedPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + yield imports.SearchProvider.performSearch(gBrowser, searchData); + // just check if the browser has loaded. we can't really introspect because + // the browser has had a remoteness switch, from main process to content process + yield browserLoadedPromise; + is(gBrowser.selectedBrowser.contentWindow.location.href, + "http://example.com/?q=test", "should match search URL of default engine."); + + // suggestions has correct properties + var suggestionData = { + engineName: Services.search.currentEngine.name, + searchString: "test", + }; + var suggestions = yield imports.SearchProvider.getSuggestions(gBrowser, suggestionData); + var suggestionProps = hasProp(suggestions); + ["engineName", "searchString", "formHistory", "remote"].forEach(suggestionProps); + + // ensure that the search string has been added to the form history suggestions + yield addHistoryPromise; + suggestions = yield imports.SearchProvider.getSuggestions(gBrowser, suggestionData); + var { + formHistory + } = suggestions; + ok(formHistory.length !== 0, "a form history was created"); + is(formHistory[0], searchData.searchString, "the search string has been added to form history"); + + // remove the entry we just added from the form history and ensure it no longer appears as a suggestion + let removeHistoryPromise = new Promise((resolve, reject) => { + Services.obs.addObserver(function onAdd(subject, topic, data) { // jshint ignore:line + if (data === "formhistory-remove") { + Services.obs.removeObserver(onAdd, "satchel-storage-changed"); + resolve(data); + } else { + reject(); + } + }, "satchel-storage-changed", false); + }); + + yield imports.SearchProvider.removeFormHistoryEntry(gBrowser, searchData.searchString); + yield removeHistoryPromise; + suggestions = yield imports.SearchProvider.getSuggestions(gBrowser, suggestionData); + ok(suggestions.formHistory.length === 0, "entry has been removed from form history"); + }); +}); + +add_task(function* testFetchFailure() { + yield BrowserTestUtils.withNewTab({ + gBrowser, + url: "about:newTab", + }, function* () { + + // ensure that the fetch failure is handled when the suggestions return a null value due to their + // asynchronous nature + let {controller} = imports.SearchProvider._suggestionDataForBrowser(gBrowser); + let oldFetch = controller.fetch; + controller.fetch = function(searchTerm, privateMode, engine) { //jshint ignore:line + let promise = new Promise((resolve, reject) => { //jshint ignore:line + reject(); + }); + return promise; + }; + + // this should throw, since the promise rejected + let suggestionData = { + engineName: Services.search.currentEngine.name, + searchString: "test", + }; + + let suggestions = yield imports.SearchProvider.getSuggestions(gBrowser, suggestionData); + + ok(suggestions === null, "suggestions returned null and the function handled the rejection"); + controller.fetch = oldFetch; + }); +}); diff --git a/browser/components/newtab/tests/xpcshell/test_NewTabPrefsProvider.js b/browser/components/newtab/tests/xpcshell/test_NewTabPrefsProvider.js new file mode 100644 index 000000000000..16bab02afabe --- /dev/null +++ b/browser/components/newtab/tests/xpcshell/test_NewTabPrefsProvider.js @@ -0,0 +1,27 @@ +"use strict"; + +/* global XPCOMUtils */ +/* global ok */ + +const Cu = Components.utils; +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NewTabPrefsProvider", + "resource:///modules/NewTabPrefsProvider.jsm"); + +function run_test() { + run_next_test(); +} + +add_task(function* test_observe() { + Services.prefs.setBoolPref("browser.newtabpage.enabled", false); + NewTabPrefsProvider.prefs.startTracking(); + let promise = new Promise(resolve => { + NewTabPrefsProvider.prefs.once("browser.newtabpage.enabled", resolve); + }); + Services.prefs.setBoolPref("browser.newtabpage.enabled", true); + let data = yield promise; + ok(data, "pref emitter triggers"); + NewTabPrefsProvider.prefs.stopTracking(); +}); diff --git a/browser/components/newtab/tests/xpcshell/test_RemoteAboutNewTab.js b/browser/components/newtab/tests/xpcshell/test_RemoteAboutNewTab.js new file mode 100644 index 000000000000..31c863d107bf --- /dev/null +++ b/browser/components/newtab/tests/xpcshell/test_RemoteAboutNewTab.js @@ -0,0 +1,43 @@ +"use strict"; + +/* global XPCOMUtils, RemoteAboutNewTab, PlacesProvider */ +/* global do_get_profile, run_next_test, add_task */ +/* global equal, ok */ +/* exported run_test */ + +const { + utils: Cu, + interfaces: Ci, +} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesProvider", + "resource:///modules/PlacesProvider.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "RemoteAboutNewTab", + "resource:///modules/RemoteAboutNewTab.jsm"); + +// ensure a profile exists +do_get_profile(); + +function run_test() { + run_next_test(); +} + +add_task(function* test_PlacesEventListener() { + RemoteAboutNewTab.init(); + let oldPageListener = RemoteAboutNewTab.pageListener; + RemoteAboutNewTab.pageListener = {}; + let promise = new Promise(resolve => { + RemoteAboutNewTab.pageListener.sendAsyncMessage = function(name, data) { + if (name == "NewTab:PlacesClearHistory") { + resolve(); + } + }; + PlacesProvider.links.emit("clearHistory"); + }); + yield promise; + RemoteAboutNewTab.pageListener = oldPageListener; + RemoteAboutNewTab.uninit(); +}); diff --git a/browser/components/newtab/tests/xpcshell/test_RemoteNewTabLocation.js b/browser/components/newtab/tests/xpcshell/test_RemoteNewTabLocation.js index e280b03426a5..470e2d821c21 100644 --- a/browser/components/newtab/tests/xpcshell/test_RemoteNewTabLocation.js +++ b/browser/components/newtab/tests/xpcshell/test_RemoteNewTabLocation.js @@ -1,39 +1,121 @@ -/* globals ok, equal, RemoteNewTabLocation, Services */ +/* globals ok, equal, RemoteNewTabLocation, NewTabPrefsProvider, Services */ +/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ "use strict"; Components.utils.import("resource:///modules/RemoteNewTabLocation.jsm"); +Components.utils.import("resource:///modules/NewTabPrefsProvider.jsm"); Components.utils.import("resource://gre/modules/Services.jsm"); Components.utils.importGlobalProperties(["URL"]); -add_task(function* () { - var notificationPromise; - let defaultHref = RemoteNewTabLocation.href; +const defaultHref = RemoteNewTabLocation.href; + +add_task(function* test_defaults() { ok(RemoteNewTabLocation.href, "Default location has an href"); ok(RemoteNewTabLocation.origin, "Default location has an origin"); ok(!RemoteNewTabLocation.overridden, "Default location is not overridden"); +}); + +add_task(function* test_overrides() { let testURL = new URL("https://example.com/"); + let notificationPromise; - notificationPromise = changeNotificationPromise(testURL.href); + notificationPromise = nextChangeNotificationPromise( + testURL.href, "Remote Location should change"); RemoteNewTabLocation.override(testURL.href); yield notificationPromise; ok(RemoteNewTabLocation.overridden, "Remote location should be overridden"); - equal(RemoteNewTabLocation.href, testURL.href, "Remote href should be the custom URL"); - equal(RemoteNewTabLocation.origin, testURL.origin, "Remote origin should be the custom URL"); + equal(RemoteNewTabLocation.href, testURL.href, + "Remote href should be the custom URL"); + equal(RemoteNewTabLocation.origin, testURL.origin, + "Remote origin should be the custom URL"); - notificationPromise = changeNotificationPromise(defaultHref); + notificationPromise = nextChangeNotificationPromise( + defaultHref, "Remote href should be reset"); RemoteNewTabLocation.reset(); yield notificationPromise; ok(!RemoteNewTabLocation.overridden, "Newtab URL should not be overridden"); - equal(RemoteNewTabLocation.href, defaultHref, "Remote href should be reset"); }); -function changeNotificationPromise(aNewURL) { +add_task(function* test_updates() { + let notificationPromise; + let expectedHref = "https://newtab.cdn.mozilla.net" + + `/v${RemoteNewTabLocation.version}` + + "/nightly" + + "/en-GB" + + "/index.html"; + Services.prefs.setBoolPref("intl.locale.matchOS", true); + Services.prefs.setCharPref("general.useragent.locale", "en-GB"); + NewTabPrefsProvider.prefs.startTracking(); + + // test update checks for prefs + notificationPromise = nextChangeNotificationPromise( + expectedHref, "Remote href should be updated"); + Services.prefs.setBoolPref("intl.locale.matchOS", false); + yield notificationPromise; + + notificationPromise = nextChangeNotificationPromise( + defaultHref, "Remote href changes back to default"); + Services.prefs.setCharPref("general.useragent.locale", "en-US"); + Services.prefs.setBoolPref("intl.locale.matchOS", true); + yield notificationPromise; + + // test update fires on override and reset + let testURL = new URL("https://example.com/"); + notificationPromise = nextChangeNotificationPromise( + testURL.href, "a notification occurs on override"); + RemoteNewTabLocation.override(testURL.href); + yield notificationPromise; + + // from overridden to default + notificationPromise = nextChangeNotificationPromise( + defaultHref, "a notification occurs on reset"); + RemoteNewTabLocation.reset(); + yield notificationPromise; + + // override to default URL from default URL + notificationPromise = nextChangeNotificationPromise( + testURL.href, "a notification only occurs for a change in overridden urls"); + RemoteNewTabLocation.override(defaultHref); + RemoteNewTabLocation.override(testURL.href); + yield notificationPromise; + + // reset twice, only one notification for default URL + notificationPromise = nextChangeNotificationPromise( + defaultHref, "reset occurs"); + RemoteNewTabLocation.reset(); + yield notificationPromise; + + notificationPromise = nextChangeNotificationPromise( + testURL.href, "a notification only occurs for a change in reset urls"); + RemoteNewTabLocation.reset(); + RemoteNewTabLocation.override(testURL.href); + yield notificationPromise; + + NewTabPrefsProvider.prefs.stopTracking(); +}); + +add_task(function* test_release_names() { + let valid_channels = RemoteNewTabLocation.channels; + let invalid_channels = new Set(["default", "invalid"]); + + for (let channel of valid_channels) { + equal(channel, RemoteNewTabLocation._releaseFromUpdateChannel(channel), + "release == channel name when valid"); + } + + for (let channel of invalid_channels) { + equal("nightly", RemoteNewTabLocation._releaseFromUpdateChannel(channel), + "release == nightly when invalid"); + } +}); + +function nextChangeNotificationPromise(aNewURL, testMessage) { return new Promise(resolve => { Services.obs.addObserver(function observer(aSubject, aTopic, aData) { // jshint ignore:line Services.obs.removeObserver(observer, aTopic); - equal(aData, aNewURL, "remote-new-tab-location-changed data should be new URL."); + equal(aData, aNewURL, testMessage); resolve(); }, "remote-new-tab-location-changed", false); }); diff --git a/browser/components/newtab/tests/xpcshell/xpcshell.ini b/browser/components/newtab/tests/xpcshell/xpcshell.ini index 6eb525646088..47f478f54aa9 100644 --- a/browser/components/newtab/tests/xpcshell/xpcshell.ini +++ b/browser/components/newtab/tests/xpcshell/xpcshell.ini @@ -5,7 +5,9 @@ firefox-appdir = browser skip-if = toolkit == 'android' || toolkit == 'gonk' [test_AboutNewTabService.js] +[test_NewTabPrefsProvider.js] [test_NewTabURL.js] [test_PlacesProvider.js] +[test_RemoteAboutNewTab.js] [test_RemoteNewTabLocation.js] [test_RemoteNewTabUtils.js] diff --git a/browser/components/nsBrowserGlue.js b/browser/components/nsBrowserGlue.js index 3a74a55c7b53..dbf4c660709a 100644 --- a/browser/components/nsBrowserGlue.js +++ b/browser/components/nsBrowserGlue.js @@ -32,6 +32,9 @@ XPCOMUtils.defineLazyModuleGetter(this, "RemoteAboutNewTab", XPCOMUtils.defineLazyModuleGetter(this, "RemoteNewTabUtils", "resource:///modules/RemoteNewTabUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabPrefsProvider", + "resource:///modules/NewTabPrefsProvider.jsm"); + XPCOMUtils.defineLazyModuleGetter(this, "UITour", "resource:///modules/UITour.jsm"); @@ -848,6 +851,7 @@ BrowserGlue.prototype = { RemoteNewTabUtils.init(); RemoteNewTabUtils.links.addProvider(DirectoryLinksProvider); RemoteAboutNewTab.init(); + NewTabPrefsProvider.prefs.startTracking(); SessionStore.init(); BrowserUITelemetry.init();