summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/ExtensionSearchHandler.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/places/ExtensionSearchHandler.sys.mjs')
-rw-r--r--toolkit/components/places/ExtensionSearchHandler.sys.mjs336
1 files changed, 336 insertions, 0 deletions
diff --git a/toolkit/components/places/ExtensionSearchHandler.sys.mjs b/toolkit/components/places/ExtensionSearchHandler.sys.mjs
new file mode 100644
index 0000000000..a04e40b6c6
--- /dev/null
+++ b/toolkit/components/places/ExtensionSearchHandler.sys.mjs
@@ -0,0 +1,336 @@
+/* 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/. */
+
+// Used to keep track of all of the registered keywords, where each keyword is
+// mapped to a KeywordInfo instance.
+let gKeywordMap = new Map();
+
+// Used to keep track of the active input session.
+let gActiveInputSession = null;
+
+// Used to keep track of who has control over the active suggestion callback
+// so older callbacks can be ignored. The callback ID should increment whenever
+// the input changes or the input session ends.
+let gCurrentCallbackID = 0;
+
+// Handles keeping track of information associated to the registered keyword.
+class KeywordInfo {
+ constructor(extension, description) {
+ this._extension = extension;
+ this._description = description;
+ }
+
+ get description() {
+ return this._description;
+ }
+
+ set description(desc) {
+ this._description = desc;
+ }
+
+ get extension() {
+ return this._extension;
+ }
+}
+
+// Responsible for handling communication between the extension and the urlbar.
+class InputSession {
+ constructor(keyword, extension) {
+ this._keyword = keyword;
+ this._extension = extension;
+ this._suggestionsCallback = null;
+ this._searchFinishedCallback = null;
+ }
+
+ get keyword() {
+ return this._keyword;
+ }
+
+ addSuggestions(suggestions) {
+ if (this._suggestionsCallback) {
+ this._suggestionsCallback(suggestions);
+ }
+ }
+
+ start(eventName) {
+ this._extension.emit(eventName);
+ }
+
+ update(eventName, text, suggestionsCallback, searchFinishedCallback) {
+ // Check to see if an existing input session needs to be ended first.
+ if (this._searchFinishedCallback) {
+ this._searchFinishedCallback();
+ }
+ this._searchFinishedCallback = searchFinishedCallback;
+ this._suggestionsCallback = suggestionsCallback;
+ this._extension.emit(eventName, text, ++gCurrentCallbackID);
+ }
+
+ cancel(eventName) {
+ if (this._searchFinishedCallback) {
+ this._searchFinishedCallback();
+ this._searchFinishedCallback = null;
+ this._suggestionsCallback = null;
+ }
+ this._extension.emit(eventName);
+ }
+
+ end(eventName, text, disposition) {
+ if (this._searchFinishedCallback) {
+ this._searchFinishedCallback();
+ this._searchFinishedCallback = null;
+ this._suggestionsCallback = null;
+ }
+ this._extension.emit(eventName, text, disposition);
+ }
+}
+
+export var ExtensionSearchHandler = Object.freeze({
+ MSG_INPUT_STARTED: "webext-omnibox-input-started",
+ MSG_INPUT_CHANGED: "webext-omnibox-input-changed",
+ MSG_INPUT_ENTERED: "webext-omnibox-input-entered",
+ MSG_INPUT_CANCELLED: "webext-omnibox-input-cancelled",
+ MSG_INPUT_DELETED: "webext-omnibox-input-deleted",
+
+ /**
+ * Registers a keyword.
+ *
+ * @param {string} keyword The keyword to register.
+ * @param {Extension} extension The extension registering the keyword.
+ */
+ registerKeyword(keyword, extension) {
+ if (gKeywordMap.has(keyword)) {
+ throw new Error(
+ `The keyword provided is already registered: "${keyword}"`
+ );
+ }
+ gKeywordMap.set(keyword, new KeywordInfo(extension, extension.name));
+ },
+
+ /**
+ * Unregisters a keyword.
+ *
+ * @param {string} keyword The keyword to unregister.
+ */
+ unregisterKeyword(keyword) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+ gActiveInputSession = null;
+ gKeywordMap.delete(keyword);
+ },
+
+ /**
+ * Checks if a keyword is registered.
+ *
+ * @param {string} keyword The word to check.
+ * @return {boolean} true if the word is a registered keyword.
+ */
+ isKeywordRegistered(keyword) {
+ return gKeywordMap.has(keyword);
+ },
+
+ /**
+ * @return {boolean} true if there is an active input session.
+ */
+ hasActiveInputSession() {
+ return gActiveInputSession != null;
+ },
+
+ /**
+ * @param {string} keyword The keyword to look up.
+ * @return {string} the description to use for the heuristic result.
+ */
+ getDescription(keyword) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+ return gKeywordMap.get(keyword).description;
+ },
+
+ /**
+ * Sets the default suggestion for the registered keyword. The suggestion's
+ * description will be used for the comment in the heuristic result.
+ *
+ * @param {string} keyword The keyword.
+ * @param {string} description The description to use for the heuristic result.
+ */
+ setDefaultSuggestion(keyword, { description }) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+ gKeywordMap.get(keyword).description = description;
+ },
+
+ /**
+ * Adds suggestions for the registered keyword. This function will throw if
+ * the keyword provided is not registered or active, or if the callback ID
+ * provided is no longer equal to the active callback ID.
+ *
+ * @param {string} keyword The keyword.
+ * @param {integer} id The ID of the suggestion callback.
+ * @param {Array<Object>} suggestions An array of suggestions to provide to the urlbar.
+ */
+ addSuggestions(keyword, id, suggestions) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+
+ if (!gActiveInputSession || gActiveInputSession.keyword != keyword) {
+ throw new Error(
+ `The keyword provided is not apart of an active input session: "${keyword}"`
+ );
+ }
+
+ if (id != gCurrentCallbackID) {
+ throw new Error(
+ `The callback is no longer active for the keyword provided: "${keyword}"`
+ );
+ }
+
+ gActiveInputSession.addSuggestions(suggestions);
+ },
+
+ /**
+ * Called when the input in the urlbar begins with `<keyword><space>`.
+ *
+ * If the keyword is inactive, MSG_INPUT_STARTED is emitted and the
+ * keyword is marked as active. If the keyword is followed by any text,
+ * MSG_INPUT_CHANGED is fired with the current callback ID that can be
+ * used to provide suggestions to the urlbar while the callback ID is active.
+ * The callback is invalidated when either the input changes or the urlbar blurs.
+ *
+ * @param {object} data An object that contains
+ * {string} keyword The keyword to handle.
+ * {string} text The search text in the urlbar.
+ * {boolean} inPrivateWindow privateness of window search
+ * is occuring in.
+ * @param {Function} callback The callback used to provide search suggestions.
+ * @return {Promise} promise that resolves when the current search is complete.
+ */
+ handleSearch(data, callback) {
+ let { keyword, text } = data;
+ let keywordInfo = gKeywordMap.get(keyword);
+ if (!keywordInfo) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+
+ let { extension } = keywordInfo;
+ if (data.inPrivateWindow && !extension.privateBrowsingAllowed) {
+ return Promise.resolve(false);
+ }
+
+ if (gActiveInputSession && gActiveInputSession.keyword != keyword) {
+ throw new Error("A different input session is already ongoing");
+ }
+
+ if (!text || !text.startsWith(`${keyword} `)) {
+ throw new Error(`The text provided must start with: "${keyword} "`);
+ }
+
+ if (!callback) {
+ throw new Error("A callback must be provided");
+ }
+
+ // The search text in the urlbar currently starts with <keyword><space>, and
+ // we only want the text that follows.
+ text = text.substring(keyword.length + 1);
+
+ // We fire MSG_INPUT_STARTED once we have <keyword><space>, and only fire
+ // MSG_INPUT_CHANGED when we have text to process. This is different from Chrome's
+ // behavior, which always fires MSG_INPUT_STARTED right before MSG_INPUT_CHANGED
+ // first fires, but this is a bug in Chrome according to https://crbug.com/258911.
+ if (!gActiveInputSession) {
+ gActiveInputSession = new InputSession(keyword, extension);
+ gActiveInputSession.start(this.MSG_INPUT_STARTED);
+
+ // Resolve early if there is no text to process. There can be text to process when
+ // the input starts if the user copy/pastes the text into the urlbar.
+ if (!text.length) {
+ return Promise.resolve();
+ }
+ }
+
+ return new Promise(resolve => {
+ gActiveInputSession.update(
+ this.MSG_INPUT_CHANGED,
+ text,
+ callback,
+ resolve
+ );
+ });
+ },
+
+ /**
+ * Called when the user clicks on a suggestion that was added by
+ * an extension. MSG_INPUT_ENTERED is emitted to the extension with
+ * the keyword, the current search string, and info about how the
+ * the search should be handled. This ends the active input session.
+ *
+ * @param {string} keyword The keyword associated to the suggestion.
+ * @param {string} text The search text in the urlbar.
+ * @param {string} where How the page should be opened. Accepted values are:
+ * "current": open the page in the same tab.
+ * "tab": open the page in a new foreground tab.
+ * "tabshifted": open the page in a new background tab.
+ */
+ handleInputEntered(keyword, text, where) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+
+ if (!gActiveInputSession) {
+ throw new Error("There is no active input session");
+ }
+
+ if (gActiveInputSession && gActiveInputSession.keyword != keyword) {
+ throw new Error("A different input session is already ongoing");
+ }
+
+ if (!text || !text.startsWith(`${keyword} `)) {
+ throw new Error(`The text provided must start with: "${keyword} "`);
+ }
+
+ let dispositionMap = {
+ current: "currentTab",
+ tab: "newForegroundTab",
+ tabshifted: "newBackgroundTab",
+ };
+ let disposition = dispositionMap[where];
+
+ if (!disposition) {
+ throw new Error(`Invalid "where" argument: ${where}`);
+ }
+
+ // The search text in the urlbar currently starts with <keyword><space>, and
+ // we only want to send the text that follows.
+ text = text.substring(keyword.length + 1);
+
+ gActiveInputSession.end(this.MSG_INPUT_ENTERED, text, disposition);
+ gActiveInputSession = null;
+ },
+
+ /**
+ * Called when the user deletes a suggestion that was added by
+ * an extension. MSG_INPUT_DELETED is emitted to the extension with
+ * the description of the suggestion that was deleted.
+ *
+ * @param {string} text The description of the suggestion.
+ */
+ handleInputDeleted(text) {
+ return gActiveInputSession.update(this.MSG_INPUT_DELETED, text);
+ },
+
+ /**
+ * If the user has ended the keyword input session without accepting the input,
+ * MSG_INPUT_CANCELLED is emitted and the input session is ended.
+ */
+ handleInputCancelled() {
+ if (!gActiveInputSession) {
+ throw new Error("There is no active input session");
+ }
+ gActiveInputSession.cancel(this.MSG_INPUT_CANCELLED);
+ gActiveInputSession = null;
+ },
+});