diff options
Diffstat (limited to 'toolkit/components/places/ExtensionSearchHandler.sys.mjs')
-rw-r--r-- | toolkit/components/places/ExtensionSearchHandler.sys.mjs | 336 |
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; + }, +}); |