Bug 1210410 - Implement search messages for the Remote New Tab page r=emtwo r=oyiptong

--HG--
extra : commitid : AV5EJ8ZskmP
This commit is contained in:
Ursula Sarracini 2015-10-29 11:57:56 -04:00
parent 9d15b19f48
commit bb1ca7d91d
6 changed files with 570 additions and 12 deletions

View File

@ -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, Task, SearchProvider, RemoteNewTabUtils, BackgroundPageThumbs,
RemotePages, PageThumbs, RemoteDirectoryLinksProvider, RemoteNewTabLocation*/
/* globals Services, XPCOMUtils, RemotePages, RemoteNewTabLocation, RemoteNewTabUtils, Task */
/* globals BackgroundPageThumbs, PageThumbs, RemoteDirectoryLinksProvider */
/* exported RemoteAboutNewTab */
"use strict";
@ -31,6 +31,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "RemoteDirectoryLinksProvider",
"resource:///modules/RemoteDirectoryLinksProvider.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "RemoteNewTabLocation",
"resource:///modules/RemoteNewTabLocation.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "SearchProvider",
"resource:///modules/SearchProvider.jsm");
let RemoteAboutNewTab = {
@ -44,13 +46,69 @@ 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._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);
},
/**
* Initializes the grid for the first time when the page loads.
* Fetch all the links and send them down to the child to populate
@ -147,7 +205,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 +237,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 +253,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 +282,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 +295,8 @@ 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);
},
/**
@ -220,10 +305,13 @@ 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");
},
QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
Ci.nsISupportsWeakReference]),
Ci.nsISupportsWeakReference
]),
uninit: function() {
this._removeObservers();

View File

@ -8,7 +8,7 @@ Components.utils.import("resource://gre/modules/Services.jsm");
Components.utils.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";
const DEFAULT_PAGE_LOCATION = "https://newtab.cdn.mozilla.net/v2/nightly/en-US/index.html";
this.RemoteNewTabLocation = {
_url: new URL(DEFAULT_PAGE_LOCATION),

View File

@ -0,0 +1,300 @@
/* 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();

View File

@ -17,4 +17,5 @@ EXTRA_JS_MODULES += [
'RemoteDirectoryLinksProvider.jsm',
'RemoteNewTabLocation.jsm',
'RemoteNewTabUtils.jsm',
'SearchProvider.jsm',
]

View File

@ -3,3 +3,4 @@ support-files =
dummy_page.html
[browser_remotenewtab_pageloads.js]
[browser_SearchProvider.js]

View File

@ -0,0 +1,168 @@
/* 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);
});
var win = yield imports.SearchProvider.performSearch(gBrowser, searchData);
var result = yield new Promise(resolve => {
const pageShow = function() {
win.gBrowser.tabs[1].linkedBrowser.removeEventListener("pageshow", pageShow);
var tab = win.gBrowser.tabContainer.tabbrowser;
var url = tab.selectedTab.linkedBrowser.contentWindow.location.href;
BrowserTestUtils.removeTab(tab.selectedTab);
resolve(url);
};
ok(win.gBrowser.tabs[1], "search opened a new tab");
win.gBrowser.tabs[1].linkedBrowser.addEventListener("pageshow", pageShow);
});
is(result, "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;
});
});