forked from mirrors/gecko-dev
Backed out changeset 83c87140dc3d (bug 888600) Backed out changeset 2efb9b1753f6 (bug 888600) Backed out changeset af5303781961 (bug 888600) Backed out changeset 79ef59047e63 (bug 888600) Backed out changeset 30d568d628dd (bug 888600) Backed out changeset c7bd4c6c9741 (bug 888600) --HG-- extra : histedit_source : 791b22f6770f4fead2f909478a93d65d85829fe0%2Cbb387309e90f53e1dde45dcf8cf4ebedcc6e5c5e
555 lines
18 KiB
JavaScript
555 lines
18 KiB
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/. */
|
|
"use strict";
|
|
|
|
var EXPORTED_SYMBOLS = [
|
|
"ContentSearch",
|
|
];
|
|
|
|
ChromeUtils.import("resource://gre/modules/Services.jsm");
|
|
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
|
|
|
|
Cu.importGlobalProperties(["XMLHttpRequest"]);
|
|
|
|
ChromeUtils.defineModuleGetter(this, "FormHistory",
|
|
"resource://gre/modules/FormHistory.jsm");
|
|
ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
|
|
"resource://gre/modules/PrivateBrowsingUtils.jsm");
|
|
ChromeUtils.defineModuleGetter(this, "SearchSuggestionController",
|
|
"resource://gre/modules/SearchSuggestionController.jsm");
|
|
|
|
const INBOUND_MESSAGE = "ContentSearch";
|
|
const OUTBOUND_MESSAGE = INBOUND_MESSAGE;
|
|
const MAX_LOCAL_SUGGESTIONS = 3;
|
|
const MAX_SUGGESTIONS = 6;
|
|
|
|
/**
|
|
* ContentSearch receives messages named INBOUND_MESSAGE and sends messages
|
|
* named OUTBOUND_MESSAGE. The data of each message is expected to look like
|
|
* { type, data }. type is the message's type (or subtype if you consider the
|
|
* type of the message itself to be INBOUND_MESSAGE), and data is data that is
|
|
* specific to the type.
|
|
*
|
|
* Inbound messages have the following types:
|
|
*
|
|
* AddFormHistoryEntry
|
|
* Adds an entry to the search form history.
|
|
* data: the entry, a string
|
|
* GetSuggestions
|
|
* Retrieves an array of search suggestions given a search string.
|
|
* data: { engineName, searchString }
|
|
* GetState
|
|
* Retrieves the current search engine state.
|
|
* data: null
|
|
* GetStrings
|
|
* Retrieves localized search UI strings.
|
|
* data: null
|
|
* ManageEngines
|
|
* Opens the search engine management window.
|
|
* data: null
|
|
* RemoveFormHistoryEntry
|
|
* Removes an entry from the search form history.
|
|
* data: the entry, a string
|
|
* Search
|
|
* Performs a search.
|
|
* Any GetSuggestions messages in the queue from the same target will be
|
|
* cancelled.
|
|
* data: { engineName, searchString, healthReportKey, searchPurpose }
|
|
* SetCurrentEngine
|
|
* Sets the current engine.
|
|
* data: the name of the engine
|
|
* SpeculativeConnect
|
|
* Speculatively connects to an engine.
|
|
* data: the name of the engine
|
|
*
|
|
* Outbound messages have the following types:
|
|
*
|
|
* CurrentEngine
|
|
* Broadcast when the current engine changes.
|
|
* data: see _currentEngineObj
|
|
* CurrentState
|
|
* Broadcast when the current search state changes.
|
|
* data: see currentStateObj
|
|
* State
|
|
* Sent in reply to GetState.
|
|
* data: see currentStateObj
|
|
* Strings
|
|
* Sent in reply to GetStrings
|
|
* data: Object containing string names and values for the current locale.
|
|
* Suggestions
|
|
* Sent in reply to GetSuggestions.
|
|
* data: see _onMessageGetSuggestions
|
|
* SuggestionsCancelled
|
|
* Sent in reply to GetSuggestions when pending GetSuggestions events are
|
|
* cancelled.
|
|
* data: null
|
|
*/
|
|
|
|
var ContentSearch = {
|
|
|
|
// Inbound events are queued and processed in FIFO order instead of handling
|
|
// them immediately, which would result in non-FIFO responses due to the
|
|
// asynchrononicity added by converting image data URIs to ArrayBuffers.
|
|
_eventQueue: [],
|
|
_currentEventPromise: null,
|
|
|
|
// This is used to handle search suggestions. It maps xul:browsers to objects
|
|
// { controller, previousFormHistoryResult }. See _onMessageGetSuggestions.
|
|
_suggestionMap: new WeakMap(),
|
|
|
|
// Resolved when we finish shutting down.
|
|
_destroyedPromise: null,
|
|
|
|
// The current controller and browser in _onMessageGetSuggestions. Allows
|
|
// fetch cancellation from _cancelSuggestions.
|
|
_currentSuggestion: null,
|
|
|
|
init() {
|
|
Services.obs.addObserver(this, "browser-search-engine-modified");
|
|
Services.obs.addObserver(this, "shutdown-leaks-before-check");
|
|
Services.prefs.addObserver("browser.search.hiddenOneOffs", this);
|
|
this._stringBundle = Services.strings.createBundle("chrome://global/locale/autocomplete.properties");
|
|
},
|
|
|
|
get searchSuggestionUIStrings() {
|
|
if (this._searchSuggestionUIStrings) {
|
|
return this._searchSuggestionUIStrings;
|
|
}
|
|
this._searchSuggestionUIStrings = {};
|
|
let searchBundle = Services.strings.createBundle("chrome://browser/locale/search.properties");
|
|
let stringNames = ["searchHeader", "searchForSomethingWith",
|
|
"searchWithHeader", "searchSettings"];
|
|
|
|
for (let name of stringNames) {
|
|
this._searchSuggestionUIStrings[name] = searchBundle.GetStringFromName(name);
|
|
}
|
|
return this._searchSuggestionUIStrings;
|
|
},
|
|
|
|
destroy() {
|
|
if (this._destroyedPromise) {
|
|
return this._destroyedPromise;
|
|
}
|
|
|
|
Services.obs.removeObserver(this, "browser-search-engine-modified");
|
|
Services.obs.removeObserver(this, "shutdown-leaks-before-check");
|
|
|
|
this._eventQueue.length = 0;
|
|
this._destroyedPromise = Promise.resolve(this._currentEventPromise);
|
|
return this._destroyedPromise;
|
|
},
|
|
|
|
/**
|
|
* Focuses the search input in the page with the given message manager.
|
|
* @param messageManager
|
|
* The MessageManager object of the selected browser.
|
|
*/
|
|
focusInput(messageManager) {
|
|
messageManager.sendAsyncMessage(OUTBOUND_MESSAGE, {
|
|
type: "FocusInput"
|
|
});
|
|
},
|
|
|
|
// Listeners and observers are added in nsBrowserGlue.js
|
|
receiveMessage(msg) {
|
|
// Add a temporary event handler that exists only while the message is in
|
|
// the event queue. If the message's source docshell changes browsers in
|
|
// the meantime, then we need to update msg.target. event.detail will be
|
|
// the docshell's new parent <xul:browser> element.
|
|
msg.handleEvent = event => {
|
|
let browserData = this._suggestionMap.get(msg.target);
|
|
if (browserData) {
|
|
this._suggestionMap.delete(msg.target);
|
|
this._suggestionMap.set(event.detail, browserData);
|
|
}
|
|
msg.target.removeEventListener("SwapDocShells", msg, true);
|
|
msg.target = event.detail;
|
|
msg.target.addEventListener("SwapDocShells", msg, true);
|
|
};
|
|
msg.target.addEventListener("SwapDocShells", msg, true);
|
|
|
|
// Search requests cause cancellation of all Suggestion requests from the
|
|
// same browser.
|
|
if (msg.data.type === "Search") {
|
|
this._cancelSuggestions(msg);
|
|
}
|
|
|
|
this._eventQueue.push({
|
|
type: "Message",
|
|
data: msg,
|
|
});
|
|
this._processEventQueue();
|
|
},
|
|
|
|
observe(subj, topic, data) {
|
|
switch (topic) {
|
|
case "nsPref:changed":
|
|
case "browser-search-engine-modified":
|
|
this._eventQueue.push({
|
|
type: "Observe",
|
|
data,
|
|
});
|
|
this._processEventQueue();
|
|
break;
|
|
case "shutdown-leaks-before-check":
|
|
subj.wrappedJSObject.client.addBlocker(
|
|
"ContentSearch: Wait until the service is destroyed", () => this.destroy());
|
|
break;
|
|
}
|
|
},
|
|
|
|
removeFormHistoryEntry(msg, entry) {
|
|
let browserData = this._suggestionDataForBrowser(msg.target);
|
|
if (browserData && browserData.previousFormHistoryResult) {
|
|
let { previousFormHistoryResult } = browserData;
|
|
for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
|
|
if (previousFormHistoryResult.getValueAt(i) === entry) {
|
|
previousFormHistoryResult.removeValueAt(i, true);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
performSearch(msg, data) {
|
|
this._ensureDataHasProperties(data, [
|
|
"engineName",
|
|
"searchString",
|
|
"healthReportKey",
|
|
"searchPurpose",
|
|
]);
|
|
let engine = Services.search.getEngineByName(data.engineName);
|
|
let submission = engine.getSubmission(data.searchString, "", data.searchPurpose);
|
|
let browser = msg.target;
|
|
let win = browser.ownerGlobal;
|
|
if (!win) {
|
|
// The browser may have been closed between the time its content sent the
|
|
// message and the time we handle it.
|
|
return;
|
|
}
|
|
let where = win.whereToOpenLink(data.originalEvent);
|
|
|
|
// There is a chance that by the time we receive the search message, the user
|
|
// has switched away from the tab that triggered the search. If, based on the
|
|
// event, we need to load the search in the same tab that triggered it (i.e.
|
|
// where === "current"), openUILinkIn will not work because that tab is no
|
|
// longer the current one. For this case we manually load the URI.
|
|
if (where === "current") {
|
|
// Since we're going to load the search in the same browser, blur the search
|
|
// UI to prevent further interaction before we start loading.
|
|
this._reply(msg, "Blur");
|
|
browser.loadURIWithFlags(submission.uri.spec,
|
|
Ci.nsIWebNavigation.LOAD_FLAGS_NONE, null, null,
|
|
submission.postData);
|
|
} else {
|
|
let params = {
|
|
postData: submission.postData,
|
|
inBackground: Services.prefs.getBoolPref("browser.tabs.loadInBackground"),
|
|
};
|
|
win.openUILinkIn(submission.uri.spec, where, params);
|
|
}
|
|
win.BrowserSearch.recordSearchInTelemetry(engine, data.healthReportKey,
|
|
{ selection: data.selection });
|
|
},
|
|
|
|
async getSuggestions(engineName, searchString, browser) {
|
|
let engine = Services.search.getEngineByName(engineName);
|
|
if (!engine) {
|
|
throw new Error("Unknown engine name: " + engineName);
|
|
}
|
|
|
|
let browserData = this._suggestionDataForBrowser(browser, true);
|
|
let { controller } = browserData;
|
|
let ok = SearchSuggestionController.engineOffersSuggestions(engine);
|
|
controller.maxLocalResults = ok ? MAX_LOCAL_SUGGESTIONS : MAX_SUGGESTIONS;
|
|
controller.maxRemoteResults = ok ? MAX_SUGGESTIONS : 0;
|
|
let priv = PrivateBrowsingUtils.isBrowserPrivate(browser);
|
|
// fetch() rejects its promise if there's a pending request, but since we
|
|
// process our event queue serially, there's never a pending request.
|
|
this._currentSuggestion = { controller, target: browser };
|
|
let suggestions = await controller.fetch(searchString, priv, engine);
|
|
this._currentSuggestion = null;
|
|
|
|
// suggestions will be null if the request was cancelled
|
|
let result = {};
|
|
if (!suggestions) {
|
|
return result;
|
|
}
|
|
|
|
// Keep the form history result so RemoveFormHistoryEntry can remove entries
|
|
// from it. Keeping only one result isn't foolproof because the client may
|
|
// try to remove an entry from one set of suggestions after it has requested
|
|
// more but before it's received them. In that case, the entry may not
|
|
// appear in the new suggestions. But that should happen rarely.
|
|
browserData.previousFormHistoryResult = suggestions.formHistoryResult;
|
|
result = {
|
|
engineName,
|
|
term: suggestions.term,
|
|
local: suggestions.local,
|
|
remote: suggestions.remote,
|
|
};
|
|
return result;
|
|
},
|
|
|
|
async addFormHistoryEntry(browser, entry = "") {
|
|
let isPrivate = false;
|
|
try {
|
|
// isBrowserPrivate assumes that the passed-in browser has all the normal
|
|
// properties, which won't be true if the browser has been destroyed.
|
|
// That may be the case here due to the asynchronous nature of messaging.
|
|
isPrivate = PrivateBrowsingUtils.isBrowserPrivate(browser.target);
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
if (isPrivate || entry === "") {
|
|
return false;
|
|
}
|
|
let browserData = this._suggestionDataForBrowser(browser.target, true);
|
|
FormHistory.update({
|
|
op: "bump",
|
|
fieldname: browserData.controller.formHistoryParam,
|
|
value: entry,
|
|
}, {
|
|
handleCompletion: () => {},
|
|
handleError: err => {
|
|
Cu.reportError("Error adding form history entry: " + err);
|
|
},
|
|
});
|
|
return true;
|
|
},
|
|
|
|
async currentStateObj(uriFlag = false) {
|
|
let state = {
|
|
engines: [],
|
|
currentEngine: await this._currentEngineObj(),
|
|
};
|
|
if (uriFlag) {
|
|
state.currentEngine.iconBuffer = Services.search.currentEngine.getIconURLBySize(16, 16);
|
|
}
|
|
let pref = Services.prefs.getCharPref("browser.search.hiddenOneOffs");
|
|
let hiddenList = pref ? pref.split(",") : [];
|
|
for (let engine of Services.search.getVisibleEngines()) {
|
|
let uri = engine.getIconURLBySize(16, 16);
|
|
let iconBuffer = uri;
|
|
if (!uriFlag) {
|
|
iconBuffer = await this._arrayBufferFromDataURI(uri);
|
|
}
|
|
state.engines.push({
|
|
name: engine.name,
|
|
iconBuffer,
|
|
hidden: hiddenList.includes(engine.name),
|
|
});
|
|
}
|
|
return state;
|
|
},
|
|
|
|
_processEventQueue() {
|
|
if (this._currentEventPromise || !this._eventQueue.length) {
|
|
return;
|
|
}
|
|
|
|
let event = this._eventQueue.shift();
|
|
|
|
this._currentEventPromise = (async () => {
|
|
try {
|
|
await this["_on" + event.type](event.data);
|
|
} catch (err) {
|
|
Cu.reportError(err);
|
|
} finally {
|
|
this._currentEventPromise = null;
|
|
this._processEventQueue();
|
|
}
|
|
})();
|
|
},
|
|
|
|
_cancelSuggestions(msg) {
|
|
let cancelled = false;
|
|
// cancel active suggestion request
|
|
if (this._currentSuggestion && this._currentSuggestion.target === msg.target) {
|
|
this._currentSuggestion.controller.stop();
|
|
cancelled = true;
|
|
}
|
|
// cancel queued suggestion requests
|
|
for (let i = 0; i < this._eventQueue.length; i++) {
|
|
let m = this._eventQueue[i].data;
|
|
if (msg.target === m.target && m.data.type === "GetSuggestions") {
|
|
this._eventQueue.splice(i, 1);
|
|
cancelled = true;
|
|
i--;
|
|
}
|
|
}
|
|
if (cancelled) {
|
|
this._reply(msg, "SuggestionsCancelled");
|
|
}
|
|
},
|
|
|
|
async _onMessage(msg) {
|
|
let methodName = "_onMessage" + msg.data.type;
|
|
if (methodName in this) {
|
|
await this._initService();
|
|
await this[methodName](msg, msg.data.data);
|
|
if (!Cu.isDeadWrapper(msg.target)) {
|
|
msg.target.removeEventListener("SwapDocShells", msg, true);
|
|
}
|
|
}
|
|
},
|
|
|
|
_onMessageGetState(msg, data) {
|
|
return this.currentStateObj().then(state => {
|
|
this._reply(msg, "State", state);
|
|
});
|
|
},
|
|
|
|
_onMessageGetStrings(msg, data) {
|
|
this._reply(msg, "Strings", this.searchSuggestionUIStrings);
|
|
},
|
|
|
|
_onMessageSearch(msg, data) {
|
|
this.performSearch(msg, data);
|
|
},
|
|
|
|
_onMessageSetCurrentEngine(msg, data) {
|
|
Services.search.currentEngine = Services.search.getEngineByName(data);
|
|
},
|
|
|
|
_onMessageManageEngines(msg) {
|
|
msg.target.ownerGlobal.openPreferences("paneSearch", { origin: "contentSearch" });
|
|
},
|
|
|
|
async _onMessageGetSuggestions(msg, data) {
|
|
this._ensureDataHasProperties(data, [
|
|
"engineName",
|
|
"searchString",
|
|
]);
|
|
let {engineName, searchString} = data;
|
|
let suggestions = await this.getSuggestions(engineName, searchString, msg.target);
|
|
|
|
this._reply(msg, "Suggestions", {
|
|
engineName: data.engineName,
|
|
searchString: suggestions.term,
|
|
formHistory: suggestions.local,
|
|
remote: suggestions.remote,
|
|
});
|
|
},
|
|
|
|
async _onMessageAddFormHistoryEntry(msg, entry) {
|
|
await this.addFormHistoryEntry(msg, entry);
|
|
},
|
|
|
|
_onMessageRemoveFormHistoryEntry(msg, entry) {
|
|
this.removeFormHistoryEntry(msg, entry);
|
|
},
|
|
|
|
_onMessageSpeculativeConnect(msg, engineName) {
|
|
let engine = Services.search.getEngineByName(engineName);
|
|
if (!engine) {
|
|
throw new Error("Unknown engine name: " + engineName);
|
|
}
|
|
if (msg.target.contentWindow) {
|
|
engine.speculativeConnect({
|
|
window: msg.target.contentWindow,
|
|
originAttributes: msg.target.contentPrincipal.originAttributes
|
|
});
|
|
}
|
|
},
|
|
|
|
async _onObserve(data) {
|
|
if (data === "engine-current") {
|
|
let engine = await this._currentEngineObj();
|
|
this._broadcast("CurrentEngine", engine);
|
|
} else if (data !== "engine-default") {
|
|
// engine-default is always sent with engine-current and isn't otherwise
|
|
// relevant to content searches.
|
|
let state = await this.currentStateObj();
|
|
this._broadcast("CurrentState", state);
|
|
}
|
|
},
|
|
|
|
_suggestionDataForBrowser(browser, create = false) {
|
|
let data = this._suggestionMap.get(browser);
|
|
if (!data && create) {
|
|
// Since one SearchSuggestionController instance is meant to be used per
|
|
// autocomplete widget, this means that we assume each xul:browser has at
|
|
// most one such widget.
|
|
data = {
|
|
controller: new SearchSuggestionController(),
|
|
};
|
|
this._suggestionMap.set(browser, data);
|
|
}
|
|
return data;
|
|
},
|
|
|
|
_reply(msg, type, data) {
|
|
// We reply asyncly to messages, and by the time we reply the browser we're
|
|
// responding to may have been destroyed. messageManager is null then.
|
|
if (!Cu.isDeadWrapper(msg.target) && msg.target.messageManager) {
|
|
msg.target.messageManager.sendAsyncMessage(...this._msgArgs(type, data));
|
|
}
|
|
},
|
|
|
|
_broadcast(type, data) {
|
|
Cc["@mozilla.org/globalmessagemanager;1"].
|
|
getService(Ci.nsIMessageListenerManager).
|
|
broadcastAsyncMessage(...this._msgArgs(type, data));
|
|
},
|
|
|
|
_msgArgs(type, data) {
|
|
return [OUTBOUND_MESSAGE, {
|
|
type,
|
|
data,
|
|
}];
|
|
},
|
|
|
|
async _currentEngineObj() {
|
|
let engine = Services.search.currentEngine;
|
|
let favicon = engine.getIconURLBySize(16, 16);
|
|
let placeholder = this._stringBundle.formatStringFromName(
|
|
"searchWithEngine", [engine.name], 1);
|
|
let obj = {
|
|
name: engine.name,
|
|
placeholder,
|
|
iconBuffer: await this._arrayBufferFromDataURI(favicon),
|
|
};
|
|
return obj;
|
|
},
|
|
|
|
_arrayBufferFromDataURI(uri) {
|
|
if (!uri) {
|
|
return Promise.resolve(null);
|
|
}
|
|
return new Promise(resolve => {
|
|
let xhr = new XMLHttpRequest();
|
|
xhr.open("GET", uri, true);
|
|
xhr.responseType = "arraybuffer";
|
|
xhr.onload = () => {
|
|
resolve(xhr.response);
|
|
};
|
|
xhr.onerror = xhr.onabort = xhr.ontimeout = () => {
|
|
resolve(null);
|
|
};
|
|
try {
|
|
// This throws if the URI is erroneously encoded.
|
|
xhr.send();
|
|
} catch (err) {
|
|
resolve(null);
|
|
}
|
|
});
|
|
},
|
|
|
|
_ensureDataHasProperties(data, requiredProperties) {
|
|
for (let prop of requiredProperties) {
|
|
if (!(prop in data)) {
|
|
throw new Error("Message data missing required property: " + prop);
|
|
}
|
|
}
|
|
},
|
|
|
|
_initService() {
|
|
if (!this._initServicePromise) {
|
|
this._initServicePromise =
|
|
new Promise(resolve => Services.search.init(resolve));
|
|
}
|
|
return this._initServicePromise;
|
|
},
|
|
};
|