Bug 1267810 - Add a module for registering keywords and handling keyword input sessions. r=adw
☠☠ backed out by 4769fe1cc004 ☠ ☠
authorMatthew Wein <mwein@mozilla.com>
Fri, 11 Nov 2016 02:06:14 +0000
changeset 348706 9cfc41a2869ee9a880aaae5d22a34e2daa7b64b6
parent 348705 117b0fa754f7879063174e138cfdf05d1c8dfabb
child 348707 7ef35cccfd7b37a69354f08bfa4d118e4fcc4dd3
push id10298
push userraliiev@mozilla.com
push dateMon, 14 Nov 2016 12:33:03 +0000
treeherdermozilla-aurora@7e29173b1641 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersadw
bugs1267810
milestone52.0a1
Bug 1267810 - Add a module for registering keywords and handling keyword input sessions. r=adw MozReview-Commit-ID: Ghqe5xLw67Y
browser/base/content/browser.css
browser/base/content/browser.xul
browser/base/content/urlbarBindings.xml
browser/components/nsBrowserGlue.js
browser/locales/en-US/chrome/browser/browser.dtd
browser/themes/shared/identity-block/identity-block.inc.css
toolkit/components/places/ExtensionSearchHandler.jsm
toolkit/components/places/UnifiedComplete.js
toolkit/components/places/moz.build
toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js
toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js
toolkit/components/places/tests/unifiedcomplete/xpcshell.ini
toolkit/content/widgets/autocomplete.xml
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -467,17 +467,25 @@ toolbar:not(#TabsToolbar) > #personal-bo
 }
 #PopupAutoCompleteRichResult[noactions] > richlistbox > richlistitem.overridable-action > .ac-action {
   display: none;
 }
 #PopupAutoCompleteRichResult[noactions] > richlistbox > richlistitem.overridable-action > .ac-type-icon {
   list-style-image: none;
 }
 
-#urlbar:not([actiontype="switchtab"]) > #urlbar-display-box {
+#urlbar:not([actiontype="switchtab"]):not([actiontype="extension"]) > #urlbar-display-box {
+  display: none;
+}
+
+#urlbar:not([actiontype="switchtab"]) > #urlbar-display-box > #switchtab {
+  display: none;
+}
+
+#urlbar:not([actiontype="extension"]) > #urlbar-display-box > #extension {
   display: none;
 }
 
 #PopupAutoComplete > richlistbox > richlistitem > .ac-type-icon,
 #PopupAutoComplete > richlistbox > richlistitem > .ac-site-icon,
 #PopupAutoComplete > richlistbox > richlistitem > .ac-tags,
 #PopupAutoComplete > richlistbox > richlistitem > .ac-separator,
 #PopupAutoComplete > richlistbox > richlistitem > .ac-url {
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -755,17 +755,18 @@
                 <image id="tracking-protection-icon"/>
                 <image id="connection-icon"/>
                 <hbox id="identity-icon-labels">
                   <label id="identity-icon-label" class="plain" flex="1"/>
                   <label id="identity-icon-country-label" class="plain"/>
                 </hbox>
               </box>
               <box id="urlbar-display-box" align="center">
-                <label class="urlbar-display urlbar-display-switchtab" value="&urlbar.switchToTab.label;"/>
+                <label id="switchtab" class="urlbar-display urlbar-display-switchtab" value="&urlbar.switchToTab.label;"/>
+                <label id="extension" class="urlbar-display urlbar-display-extension" value="&urlbar.extension.label;"/>
               </box>
               <hbox id="urlbar-icons">
                 <image id="page-report-button"
                        class="urlbar-icon"
                        hidden="true"
                        tooltiptext="&pageReportIcon.tooltip;"
                        onmousedown="gPopupBlockerObserver.onReportButtonMousedown(event);"/>
                 <image id="reader-mode-button"
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -51,16 +51,21 @@ file, You can obtain one at http://mozil
                     class="autocomplete-result-popupset"/>
       <children includes="toolbarbutton"/>
     </content>
 
     <implementation implements="nsIObserver, nsIDOMEventListener">
       <field name="AppConstants" readonly="true">
         (Components.utils.import("resource://gre/modules/AppConstants.jsm", {})).AppConstants;
       </field>
+
+      <field name="ExtensionSearchHandler" readonly="true">
+        (Components.utils.import("resource://gre/modules/ExtensionSearchHandler.jsm", {})).ExtensionSearchHandler;
+      </field>
+
       <constructor><![CDATA[
         this._prefs = Components.classes["@mozilla.org/preferences-service;1"]
                                 .getService(Components.interfaces.nsIPrefService)
                                 .getBranch("browser.urlbar.");
 
         this._prefs.addObserver("", this, false);
         this.clickSelectsAll = this._prefs.getBoolPref("clickSelectsAll");
         this.doubleClickSelectsAll = this._prefs.getBoolPref("doubleClickSelectsAll");
@@ -169,16 +174,20 @@ file, You can obtain one at http://mozil
                 returnValue = action.params.displayUrl;
                 break;
               }
               case "keyword": // Fall through.
               case "searchengine": {
                 returnValue = action.params.input;
                 break;
               }
+              case "extension": {
+                returnValue = action.params.content;
+                break;
+              }
             }
           } else {
             let originalUrl = ReaderMode.getOriginalUrl(aValue);
             if (originalUrl) {
               returnValue = originalUrl;
             }
           }
 
@@ -473,16 +482,23 @@ file, You can obtain one at http://mozil
                   action.params.engineName,
                   action.params.searchSuggestion || action.params.searchQuery,
                   event,
                   where,
                   openUILinkParams,
                   actionDetails
                 );
                 break;
+              case "extension":
+                this.handleRevert();
+                // Give the extension control of handling the command.
+                let searchString = action.params.content;
+                let keyword = action.params.keyword;
+                this.ExtensionSearchHandler.handleInputEntered(keyword, searchString, where);
+                return;
             }
           } else {
             // This is a fallback for add-ons and old testing code that directly
             // set value and try to confirm it. UnifiedComplete should always
             // resolve to a valid url.
             try {
               new URL(url);
             } catch (ex) {
@@ -590,17 +606,17 @@ file, You can obtain one at http://mozil
         <parameter name="searchActionDetails"/>
         <body><![CDATA[
           let engine =
             typeof(engineOrEngineName) == "string" ?
               Services.search.getEngineByName(engineOrEngineName) :
               engineOrEngineName;
           let isOneOff = this.popup.oneOffSearchButtons
               .maybeRecordTelemetry(event, openUILinkWhere, openUILinkParams);
-          // Infer the type of the even which triggered the search.
+          // Infer the type of the event which triggered the search.
           let eventType = "unknown";
           if (event instanceof KeyboardEvent) {
             eventType = "key";
           } else if (event instanceof MouseEvent) {
             eventType = "mouse";
           }
           // Augment the search action details object.
           let details = searchActionDetails || {};
@@ -1168,16 +1184,19 @@ file, You can obtain one at http://mozil
         }
       ]]></handler>
 
       <handler event="blur"><![CDATA[
         if (event.originalTarget == this.inputField) {
           this._clearNoActions();
           this.formatValue();
         }
+        if (ExtensionSearchHandler.hasActiveInputSession()) {
+          ExtensionSearchHandler.handleInputCancelled();
+        }
       ]]></handler>
 
       <handler event="dragstart" phase="capturing"><![CDATA[
         // Drag only if the gesture starts from the input field.
         if (this.inputField != event.originalTarget &&
             !(this.inputField.compareDocumentPosition(event.originalTarget) &
               Node.DOCUMENT_POSITION_CONTAINED_BY))
           return;
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -416,16 +416,17 @@ BrowserGlue.prototype = {
       history: 2,
       keyword: 3,
       searchengine: 4,
       searchsuggestion: 5,
       switchtab: 6,
       tag: 7,
       visiturl: 8,
       remotetab: 9,
+      extension: 10,
     };
     if (actionType in buckets) {
       Services.telemetry
               .getHistogramById("FX_URLBAR_SELECTED_RESULT_TYPE")
               .add(buckets[actionType]);
     } else {
       Cu.reportError("Unknown FX_URLBAR_SELECTED_RESULT_TYPE type: " +
                      actionType);
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -387,16 +387,17 @@ These should match what Safari and other
 <!ENTITY customizeMenu.removeFromMenu.label "Remove from Menu">
 <!ENTITY customizeMenu.removeFromMenu.accesskey "R">
 <!ENTITY customizeMenu.addMoreItems.label "Add More Items…">
 <!ENTITY customizeMenu.addMoreItems.accesskey "A">
 
 <!ENTITY openCmd.commandkey           "l">
 <!ENTITY urlbar.placeholder2          "Search or enter address">
 <!ENTITY urlbar.accesskey             "d">
+<!ENTITY urlbar.extension.label       "Extension:">
 <!ENTITY urlbar.switchToTab.label     "Switch to tab:">
 
 <!ENTITY urlbar.searchSuggestionsNotification.question "Would you like to improve your search experience with suggestions?">
 <!ENTITY urlbar.searchSuggestionsNotification.learnMore "Learn more…">
 <!ENTITY urlbar.searchSuggestionsNotification.learnMore.accesskey "l">
 <!ENTITY urlbar.searchSuggestionsNotification.disable "No">
 <!ENTITY urlbar.searchSuggestionsNotification.disable.accesskey "n">
 <!ENTITY urlbar.searchSuggestionsNotification.enable "Yes">
--- a/browser/themes/shared/identity-block/identity-block.inc.css
+++ b/browser/themes/shared/identity-block/identity-block.inc.css
@@ -74,16 +74,23 @@
 
 #urlbar[actiontype="searchengine"] > #identity-box > #identity-icon {
   -moz-image-region: inherit;
   list-style-image: url(chrome://global/skin/icons/autocomplete-search.svg#search-icon);
   width: 16px;
   height: 16px;
 }
 
+#urlbar[actiontype="extension"] > #identity-box > #identity-icon {
+  -moz-image-region: inherit;
+  list-style-image: url(chrome://browser/skin/addons/addon-install-anchor.svg);
+  width: 16px;
+  height: 16px;
+}
+
 /* SHARING ICON */
 
 #sharing-icon {
   width: 16px;
   height: 16px;
   margin-inline-start: -16px;
   position: relative;
   display: none;
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/ExtensionSearchHandler.jsm
@@ -0,0 +1,312 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = [ "ExtensionSearchHandler" ];
+
+// 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._searchCompleteCallback = null;
+  }
+
+  get keyword() {
+    return this._keyword;
+  }
+
+  registerSuggestionsCallback(callback) {
+    this._suggestionsCallback = callback;
+  }
+
+  registerSearchCompleteCallback(callback) {
+    this._onSearchComplete = callback;
+  }
+
+  addSuggestions(suggestions) {
+    if (this._suggestionsCallback) {
+      this._suggestionsCallback(suggestions);
+    }
+  }
+
+  start(eventName) {
+    this._extension.emit(eventName);
+  }
+
+  update(eventName, text) {
+    this._extension.emit(eventName, text, ++gCurrentCallbackID);
+    if (this._searchCompleteCallback) {
+      this._searchCompleteCallback();
+    }
+  }
+
+  cancel(eventName) {
+    this._extension.emit(eventName);
+    if (this._searchCompleteCallback) {
+      this._searchCompleteCallback();
+    }
+  }
+
+  end(eventName, text, disposition) {
+    this._extension.emit(eventName, text, disposition);
+    if (this._searchCompleteCallback) {
+      this._searchCompleteCallback();
+    }
+  }
+}
+
+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",
+
+  /**
+   * 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 {string} keyword The keyword to handle.
+   * @param {string} text The search text in the urlbar.
+   * @param {Function} callback The callback used to provide search suggestions.
+   * @return {Promise} promise that resolves when the current search is complete.
+   */
+  handleSearch(keyword, text, callback) {
+    if (!gKeywordMap.has(keyword)) {
+      throw new Error(`The keyword provided is not registered: "${keyword}"`);
+    }
+
+    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");
+    }
+
+    let {extension} = gKeywordMap.get(keyword);
+
+    // 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 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);
+
+      // Update the input session if there is text to process.
+      if (text.length) {
+        gActiveInputSession.registerSuggestionsCallback(callback);
+        gActiveInputSession.update(this.MSG_INPUT_CHANGED, text);
+      }
+      return Promise.resolve();
+    }
+
+    gActiveInputSession.registerSuggestionsCallback(callback);
+    gActiveInputSession.update(this.MSG_INPUT_CHANGED, text);
+
+    return new Promise(resolve => {
+      gActiveInputSession.registerSearchCompleteCallback(resolve);
+    });
+  },
+
+  /**
+   * Called when the user submits 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 Where 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 && 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}`);
+    }
+
+    let {extension} = gKeywordMap.get(keyword);
+
+    // 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;
+  },
+
+  /**
+   * 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;
+  }
+});
--- a/toolkit/components/places/UnifiedComplete.js
+++ b/toolkit/components/places/UnifiedComplete.js
@@ -64,16 +64,21 @@ const TELEMETRY_6_FIRST_RESULTS = "PLACE
 // The default frecency value used when inserting matches with unknown frecency.
 const FRECENCY_DEFAULT = 1000;
 
 // Remote matches are appended when local matches are below a given frecency
 // threshold (FRECENCY_DEFAULT) as soon as they arrive.  However we'll
 // always try to have at least MINIMUM_LOCAL_MATCHES local matches.
 const MINIMUM_LOCAL_MATCHES = 6;
 
+// Extensions are allowed to add suggestions if they have registered a keyword
+// with the omnibox API. This is the maximum number of suggestions an extension
+// is allowed to add for a given search string.
+const MAXIMUM_ALLOWED_EXTENSION_MATCHES = 6;
+
 // A regex that matches "single word" hostnames for whitelisting purposes.
 // The hostname will already have been checked for general validity, so we
 // don't need to be exhaustive here, so allow dashes anywhere.
 const REGEXP_SINGLEWORD_HOST = new RegExp("^[a-z0-9-]+$", "i");
 
 // Regex used to match userContextId.
 const REGEXP_USER_CONTEXT_ID = /(?:^| )user-context-id:(\d+)/;
 
@@ -259,16 +264,18 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
                                   "resource://gre/modules/Preferences.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
                                   "resource://gre/modules/Sqlite.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
                                   "resource://gre/modules/PromiseUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSearchHandler",
+                                  "resource://gre/modules/ExtensionSearchHandler.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesSearchAutocompleteProvider",
                                   "resource://gre/modules/PlacesSearchAutocompleteProvider.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesRemoteTabsAutocompleteProvider",
                                   "resource://gre/modules/PlacesRemoteTabsAutocompleteProvider.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
                                   "resource://gre/modules/BrowserUtils.jsm");
@@ -710,21 +717,26 @@ function Search(searchString, searchPara
   this._usedURLs = new Set();
   this._usedPlaceIds = new Set();
 
   // Resolved when all the remote matches have been fetched.
   this._remoteMatchesPromises = [];
 
   // The index to insert remote matches at.
   this._remoteMatchesStartIndex = 0;
+  // The index to insert local matches at.
+
+  this._localMatchesStartIndex = 0;
 
   // Counts the number of inserted local matches.
   this._localMatchesCount = 0;
   // Counts the number of inserted remote matches.
   this._remoteMatchesCount = 0;
+  // Counts the number of inserted extension matches.
+  this._extensionMatchesCount = 0;
 }
 
 Search.prototype = {
   /**
    * Enables the desired AutoComplete behavior.
    *
    * @param type
    *        The behavior type to set.
@@ -937,25 +949,45 @@ Search.prototype = {
       for (let [query, params] of [ this._adaptiveQuery,
                                     this._searchQuery ]) {
         yield conn.executeCached(query, params, this._onResultRow.bind(this));
         if (!this.pending)
           return;
       }
     }
 
-    // Ensure to fill any remaining space.
+    // Only add extension suggestions if the first token is a registered keyword
+    // and the search string has characters after the first token.
+    if (ExtensionSearchHandler.isKeywordRegistered(this._searchTokens[0]) &&
+        this._originalSearchString.length > this._searchTokens[0].length) {
+      yield this._matchExtensionSuggestions();
+      if (!this.pending)
+        return;
+    } else if (ExtensionSearchHandler.hasActiveInputSession()) {
+      ExtensionSearchHandler.handleInputCancelled();
+    }
+
+    // Ensure to fill any remaining space. Suggestions which come from extensions are
+    // inserted at the beginning, so any suggestions
     yield Promise.all(this._remoteMatchesPromises);
   }),
 
   *_matchFirstHeuristicResult(conn) {
     // We always try to make the first result a special "heuristic" result.  The
     // heuristics below determine what type of result it will be, if any.
 
-    let hasSearchTerms = this._searchTokens.length > 0 ;
+    let hasSearchTerms = this._searchTokens.length > 0;
+
+    if (hasSearchTerms) {
+      // It may be a keyword registered by an extension.
+      let matched = yield this._matchExtensionHeuristicResult();
+      if (matched) {
+        return true;
+      }
+    }
 
     if (this._enableActions && hasSearchTerms) {
       // It may be a search engine with an alias - which works like a keyword.
       let matched = yield this._matchSearchEngineAlias();
       if (matched) {
         return true;
       }
     }
@@ -1119,16 +1151,26 @@ Search.prototype = {
     let [ query, params ] = this._hostQuery;
     yield conn.executeCached(query, params, row => {
       gotResult = true;
       this._onResultRow(row);
     });
     return gotResult;
   },
 
+  _matchExtensionHeuristicResult: function* () {
+    if (ExtensionSearchHandler.isKeywordRegistered(this._searchTokens[0]) &&
+        this._originalSearchString.length > this._searchTokens[0].length) {
+      let description = ExtensionSearchHandler.getDescription(this._searchTokens[0]);
+      this._addExtensionMatch(this._originalSearchString, description);
+      return true;
+    }
+    return false;
+  },
+
   _matchPlacesKeyword: function* () {
     // The first word could be a keyword, so that's what we'll search.
     let keyword = this._searchTokens[0];
     let entry = yield PlacesUtils.keywords.fetch(this._searchTokens[0]);
     if (!entry)
       return false;
 
     let searchString = this._trimmedOriginalSearchString.substr(keyword.length + 1);
@@ -1231,16 +1273,34 @@ Search.prototype = {
     if (!match)
       return false;
 
     let query = this._originalSearchString;
     this._addSearchEngineMatch(match, query);
     return true;
   },
 
+  _addExtensionMatch(content, comment) {
+    if (this._extensionMatchesCount >= MAXIMUM_ALLOWED_EXTENSION_MATCHES) {
+      return;
+    }
+
+    this._addMatch({
+      value: PlacesUtils.mozActionURI("extension", {
+        content,
+        keyword: this._searchTokens[0]
+      }),
+      comment,
+      icon: "chrome://browser/content/extension.svg",
+      style: "action extension",
+      frecency: FRECENCY_DEFAULT,
+      extension: true,
+    });
+  },
+
   _addSearchEngineMatch(match, query, suggestion) {
     let actionURLParams = {
       engineName: match.engineName,
       input: suggestion || this._originalSearchString,
       searchQuery: query,
     };
     if (suggestion)
       actionURLParams.searchSuggestion = suggestion;
@@ -1254,16 +1314,28 @@ Search.prototype = {
       comment: match.engineName,
       icon: match.iconUrl,
       style: "action searchengine",
       frecency: FRECENCY_DEFAULT,
       remote: !!suggestion
     });
   },
 
+  *_matchExtensionSuggestions() {
+    let promise = ExtensionSearchHandler.handleSearch(this._searchTokens[0], this._originalSearchString,
+      suggestions => {
+        suggestions.forEach(suggestion => {
+          let content = `${this._searchTokens[0]} ${suggestion.content}`;
+          this._addExtensionMatch(content, suggestion.description);
+        });
+      }
+    );
+    this._remoteMatchesPromises.push(promise);
+  },
+
   *_matchRemoteTabs() {
     let matches = yield PlacesRemoteTabsAutocompleteProvider.getMatches(this._originalSearchString);
     for (let {url, title, icon, deviceName} of matches) {
       // It's rare that Sync supplies the icon for the page (but if it does, it
       // is a string URL)
       if (!icon) {
         try {
           let favicon = yield PlacesUtils.promiseFaviconLinkUrl(url);
@@ -1459,16 +1531,21 @@ Search.prototype = {
   },
 
   _getInsertIndexForMatch(match) {
     let index = 0;
     if (match.remote) {
       // Append after local matches.
       index = this._remoteMatchesStartIndex + this._remoteMatchesCount;
       this._remoteMatchesCount++;
+    } else if (match.extension) {
+      index = this._localMatchesStartIndex;
+      this._localMatchesStartIndex++;
+      this._remoteMatchesStartIndex++;
+      this._extensionMatchesCount++;
     } else {
       // This is a local match.
       if (match.frecency > FRECENCY_DEFAULT ||
           this._localMatchesCount < MINIMUM_LOCAL_MATCHES) {
         // Append before remote matches.
         index = this._remoteMatchesStartIndex;
         this._remoteMatchesStartIndex++
       } else {
--- a/toolkit/components/places/moz.build
+++ b/toolkit/components/places/moz.build
@@ -59,16 +59,17 @@ if CONFIG['MOZ_PLACES']:
 
     EXTRA_JS_MODULES += [
         'BookmarkHTMLUtils.jsm',
         'BookmarkJSONUtils.jsm',
         'Bookmarks.jsm',
         'ClusterLib.js',
         'ColorAnalyzer_worker.js',
         'ColorConversion.js',
+        'ExtensionSearchHandler.jsm',
         'History.jsm',
         'PlacesBackups.jsm',
         'PlacesDBUtils.jsm',
         'PlacesRemoteTabsAutocompleteProvider.jsm',
         'PlacesSearchAutocompleteProvider.jsm',
         'PlacesSyncUtils.jsm',
         'PlacesTransactions.jsm',
         'PlacesUtils.jsm',
--- a/toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js
+++ b/toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js
@@ -162,30 +162,31 @@ function* check_autocomplete(test) {
                      .getService(Ci.nsIAutoCompleteController);
   controller.input = input;
 
   let numSearchesStarted = 0;
   input.onSearchBegin = () => {
     do_print("onSearchBegin received");
     numSearchesStarted++;
   };
-  let deferred = Promise.defer();
-  input.onSearchComplete = () => {
-    do_print("onSearchComplete received");
-    deferred.resolve();
-  }
-
+  let searchCompletePromise = new Promise(resolve => {
+    input.onSearchComplete = () => {
+      do_print("onSearchComplete received");
+      resolve();
+    }
+  });
   let expectedSearches = 1;
   if (test.incompleteSearch) {
     controller.startSearch(test.incompleteSearch);
     expectedSearches++;
   }
+
   do_print("Searching for: '" + test.search + "'");
   controller.startSearch(test.search);
-  yield deferred.promise;
+  yield searchCompletePromise;
 
   Assert.equal(numSearchesStarted, expectedSearches, "All searches started");
 
   // Check to see the expected uris and titles match up. If 'enable-actions'
   // is specified, we check that the first specified match is the first
   // controller value (as this is the "special" always selected item), but the
   // rest can match in any order.
   // If 'enable-actions' is not specified, they can match in any order.
@@ -410,16 +411,32 @@ function makeVisitMatch(input, url, extr
 function makeSwitchToTabMatch(url, extra = {}) {
   return {
     uri: makeActionURI("switchtab", {url}),
     title: extra.title || url,
     style: [ "action", "switchtab" ],
   }
 }
 
+function makeExtensionMatch(extra = {}) {
+  let style = [ "action", "extension" ];
+  if (extra.heuristic) {
+    style.push("heuristic");
+  }
+
+  return {
+    uri: makeActionURI("extension", {
+      content: extra.content,
+      keyword: extra.keyword,
+    }),
+    title: extra.description,
+    style,
+  };
+}
+
 function setFaviconForHref(href, iconHref) {
   return new Promise(resolve => {
     PlacesUtils.favicons.setAndFetchFaviconForPage(
       NetUtil.newURI(href),
       NetUtil.newURI(iconHref),
       true,
       PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
       resolve,
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js
@@ -0,0 +1,384 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim:set ts=2 sw=2 sts=2 et:
+ * 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/. */
+
+Cu.import("resource://gre/modules/ExtensionSearchHandler.jsm");
+
+let controller = Cc["@mozilla.org/autocomplete/controller;1"].getService(Ci.nsIAutoCompleteController);
+
+add_task(function* test_correct_errors_are_thrown() {
+  let keyword = "foo";
+  let anotherKeyword = "bar";
+  let unregisteredKeyword = "baz";
+
+  // Register a keyword.
+  ExtensionSearchHandler.registerKeyword(keyword, { emit: () => {} });
+
+  // Try registering the keyword again.
+  Assert.throws(() => ExtensionSearchHandler.registerKeyword(keyword, { emit: () => {} }));
+
+  // Register a different keyword.
+  ExtensionSearchHandler.registerKeyword(anotherKeyword, { emit: () => {} });
+
+  // Try calling handleSearch for an unregistered keyword.
+  Assert.throws(() => ExtensionSearchHandler.handleSearch(unregisteredKeyword, `${unregisteredKeyword} `, () => {}));
+
+  // Try calling handleSearch without a callback.
+  Assert.throws(() => ExtensionSearchHandler.handleSearch(unregisteredKeyword, `${unregisteredKeyword} `));
+
+  // Try getting the description for a keyword which isn't registered.
+  Assert.throws(() => ExtensionSearchHandler.getDescription(unregisteredKeyword));
+
+  // Try getting the extension name for a keyword which isn't registered.
+  Assert.throws(() => ExtensionSearchHandler.getExtensionName(unregisteredKeyword));
+
+  // Try setting the default suggestion for a keyword which isn't registered.
+  Assert.throws(() => ExtensionSearchHandler.setDefaultSuggestion(unregisteredKeyword, "suggestion"));
+
+  // Try calling handleInputCancelled when there is no active input session.
+  Assert.throws(() => ExtensionSearchHandler.handleInputCancelled());
+
+  // Try calling handleInputEntered when there is no active input session.
+  Assert.throws(() => ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} test`, "tab"));
+
+  // Start a session by calling handleSearch with the registered keyword.
+  ExtensionSearchHandler.handleSearch(keyword, `${keyword} test`, () => {});
+
+  // Try providing suggestions for an unregistered keyword.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(unregisteredKeyword, 0, []));
+
+  // Try providing suggestions for an inactive keyword.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(anotherKeyword, 0, []));
+
+  // Try calling handleSearch for an inactive keyword.
+  Assert.throws(() => ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword} `, () => {}));
+
+  // Try calling addSuggestions with an old callback ID.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(keyword, 0, []));
+
+  // Add suggestions with a valid callback ID.
+  ExtensionSearchHandler.addSuggestions(keyword, 1, []);
+
+  // Add suggestions again with a valid callback ID.
+  ExtensionSearchHandler.addSuggestions(keyword, 1, []);
+
+  // Try calling addSuggestions with a future callback ID.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(keyword, 2, []));
+
+  // End the input session by calling handleInputCancelled.
+  ExtensionSearchHandler.handleInputCancelled();
+
+  // Try calling handleInputCancelled after the session has ended.
+  Assert.throws(() => ExtensionSearchHandler.handleInputCancelled());
+
+  // Try calling handleSearch that doesn't have a space after the keyword.
+  Assert.throws(() => ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword}`, () => {}));
+
+  // Try calling handleSearch with text starting with the wrong keyword.
+  Assert.throws(() => ExtensionSearchHandler.handleSearch(anotherKeyword, `${keyword} test`, () => {}));
+
+  // Start a new session by calling handleSearch with a different keyword
+  ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword} test`, () => {});
+
+  // Try adding suggestions again with the same callback ID now that the input session has ended.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(keyword, 1, []));
+
+  // Add suggestions with a valid callback ID.
+  ExtensionSearchHandler.addSuggestions(anotherKeyword, 2, []);
+
+  // Try adding suggestions with a valid callback ID but a different keyword.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(keyword, 2, []));
+
+  // Try adding suggestions with a valid callback ID but an unregistered keyword.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(unregisteredKeyword, 2, []));
+
+  // Set the default suggestion.
+  ExtensionSearchHandler.setDefaultSuggestion(anotherKeyword, {description: "test result"});
+
+  // Try ending the session using handleInputEntered with a different keyword.
+  Assert.throws(() => ExtensionSearchHandler.handleInputEntered(keyword, `${keyword} test`, "tab"));
+
+  // Try calling handleInputEntered with invalid text.
+  Assert.throws(() => ExtensionSearchHandler.handleInputEntered(anotherKeyword, ` test`, "tab"));
+
+  // Try calling handleInputEntered with an invalid disposition.
+  Assert.throws(() => ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} test`, "invalid"));
+
+  // End the session by calling handleInputEntered.
+  ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} test`, "tab");
+
+  // Try calling handleInputEntered after the session has ended.
+  Assert.throws(() => ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} test`, "tab"));
+
+  // Unregister the keyword.
+  ExtensionSearchHandler.unregisterKeyword(keyword);
+
+  // Try setting the default suggestion for the unregistered keyword.
+  Assert.throws(() => ExtensionSearchHandler.setDefaultSuggestion(keyword, {description: "test"}));
+
+  // Try handling a search with the unregistered keyword.
+  Assert.throws(() => ExtensionSearchHandler.handleSearch(keyword, `${keyword} test`, () => {}));
+
+  // Try unregistering the keyword again.
+  Assert.throws(() => ExtensionSearchHandler.unregisterKeyword(keyword));
+
+  // Unregister the other keyword.
+  ExtensionSearchHandler.unregisterKeyword(anotherKeyword);
+
+  // Try unregistering the word which was never registered.
+  Assert.throws(() => ExtensionSearchHandler.unregisterKeyword(unregisteredKeyword));
+
+  // Try setting the default suggestion for a word that was never registered.
+  Assert.throws(() => ExtensionSearchHandler.setDefaultSuggestion(unregisteredKeyword, {description: "test"}));
+
+  yield cleanup();
+});
+
+add_task(function* test_correct_events_are_emitted() {
+  let events = [];
+  function checkEvents(expectedEvents) {
+    Assert.equal(events.length, expectedEvents.length, "The correct number of events fired");
+    expectedEvents.forEach((e, i) => Assert.equal(e, events[i], `Expected "${e}" event to fire`));
+    events = [];
+  }
+
+  let mockExtension = { emit: message => events.push(message) };
+
+  let keyword = "foo";
+  let anotherKeyword = "bar";
+
+  ExtensionSearchHandler.registerKeyword(keyword, mockExtension);
+  ExtensionSearchHandler.registerKeyword(anotherKeyword, mockExtension);
+
+  ExtensionSearchHandler.handleSearch(keyword, `${keyword} `, () => {});
+  checkEvents([ExtensionSearchHandler.MSG_INPUT_STARTED]);
+
+  ExtensionSearchHandler.handleSearch(keyword, `${keyword} f`, () => {});
+  checkEvents([ExtensionSearchHandler.MSG_INPUT_CHANGED]);
+
+  ExtensionSearchHandler.handleInputEntered(keyword, `${keyword} f`, "tab");
+  checkEvents([ExtensionSearchHandler.MSG_INPUT_ENTERED]);
+
+  ExtensionSearchHandler.handleSearch(keyword, `${keyword} f`, () => {});
+  checkEvents([
+    ExtensionSearchHandler.MSG_INPUT_STARTED,
+    ExtensionSearchHandler.MSG_INPUT_CHANGED
+  ]);
+
+  ExtensionSearchHandler.handleInputCancelled();
+  checkEvents([ExtensionSearchHandler.MSG_INPUT_CANCELLED]);
+
+  ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword} baz`, () => {});
+  checkEvents([
+    ExtensionSearchHandler.MSG_INPUT_STARTED,
+    ExtensionSearchHandler.MSG_INPUT_CHANGED
+  ]);
+
+  ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} baz`, "tab");
+  checkEvents([ExtensionSearchHandler.MSG_INPUT_ENTERED]);
+
+  ExtensionSearchHandler.unregisterKeyword(keyword);
+});
+
+add_task(function* test_removes_suggestion_if_its_content_is_typed_in() {
+  let keyword = "test";
+  let extensionName = "Foo Bar";
+
+  let mockExtension = {
+    name: extensionName,
+    emit(message, text, id) {
+      if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) {
+        ExtensionSearchHandler.addSuggestions(keyword, id, [
+          {content: "foo", description: "first suggestion"},
+          {content: "bar", description: "second suggestion"},
+          {content: "baz", description: "third suggestion"},
+        ]);
+        controller.stopSearch();
+      }
+    }
+  };
+
+  ExtensionSearchHandler.registerKeyword(keyword, mockExtension);
+
+  yield check_autocomplete({
+    search: `${keyword} unmatched`,
+    searchParam: "enable-actions",
+    matches: [
+      makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} unmatched`}),
+      makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"})
+    ]
+  });
+
+  yield check_autocomplete({
+    search: `${keyword} foo`,
+    searchParam: "enable-actions",
+    matches: [
+      makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} foo`}),
+      makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"})
+    ]
+  });
+
+  yield check_autocomplete({
+    search: `${keyword} bar`,
+    searchParam: "enable-actions",
+    matches: [
+      makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} bar`}),
+      makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"})
+    ]
+  });
+
+  yield check_autocomplete({
+    search: `${keyword} baz`,
+    searchParam: "enable-actions",
+    matches: [
+      makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} baz`}),
+      makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"})
+    ]
+  });
+
+  ExtensionSearchHandler.unregisterKeyword(keyword);
+  yield cleanup();
+});
+
+add_task(function* test_extension_results_should_come_first() {
+  let keyword = "test";
+  let extensionName = "Omnibox Example";
+
+  let uri = NetUtil.newURI(`http://a.com/b`);
+  yield PlacesTestUtils.addVisits([
+    { uri, title: `${keyword} -` },
+  ]);
+
+  let mockExtension = {
+    name: extensionName,
+    emit(message, text, id) {
+      if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) {
+        ExtensionSearchHandler.addSuggestions(keyword, id, [
+          {content: "foo", description: "first suggestion"},
+          {content: "bar", description: "second suggestion"},
+          {content: "baz", description: "third suggestion"},
+        ]);
+      }
+      controller.stopSearch();
+    }
+  };
+
+  ExtensionSearchHandler.registerKeyword(keyword, mockExtension);
+
+  // Start an input session before testing MSG_INPUT_CHANGED.
+  ExtensionSearchHandler.handleSearch(keyword, `${keyword} `, () => {});
+
+  yield check_autocomplete({
+    search: `${keyword} -`,
+    searchParam: "enable-actions",
+    matches: [
+      makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} -`}),
+      makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"}),
+      { uri, title: `${keyword} -` }
+    ]
+  });
+
+  ExtensionSearchHandler.unregisterKeyword(keyword);
+  yield cleanup();
+});
+
+add_task(function* test_setting_the_default_suggestion() {
+  let keyword = "test";
+  let extensionName = "Omnibox Example";
+
+  let mockExtension = {
+    name: extensionName,
+    emit(message, text, id) {
+      if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) {
+        ExtensionSearchHandler.addSuggestions(keyword, id, []);
+      }
+      controller.stopSearch();
+    }
+  };
+
+  ExtensionSearchHandler.registerKeyword(keyword, mockExtension);
+
+  ExtensionSearchHandler.setDefaultSuggestion(keyword, {
+    description: "hello world"
+  });
+
+  let searchString = `${keyword} search query`;
+  yield check_autocomplete({
+    search: searchString,
+    searchParam: "enable-actions",
+    matches: [
+      makeExtensionMatch({heuristic: true, keyword, description: "hello world", content: searchString}),
+    ]
+  });
+
+  ExtensionSearchHandler.setDefaultSuggestion(keyword, {
+    description: "foo bar"
+  });
+
+  yield check_autocomplete({
+    search: searchString,
+    searchParam: "enable-actions",
+    matches: [
+      makeExtensionMatch({heuristic: true, keyword, description: "foo bar", content: searchString}),
+    ]
+  });
+
+  ExtensionSearchHandler.unregisterKeyword(keyword);
+  yield cleanup();
+});
+
+add_task(function* test_maximum_number_of_suggestions_is_enforced() {
+  let keyword = "test";
+  let extensionName = "Omnibox Example";
+
+  let mockExtension = {
+    name: extensionName,
+    emit(message, text, id) {
+      if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) {
+        ExtensionSearchHandler.addSuggestions(keyword, id, [
+          {content: "a", description: "first suggestion"},
+          {content: "b", description: "second suggestion"},
+          {content: "c", description: "third suggestion"},
+          {content: "d", description: "fourth suggestion"},
+          {content: "e", description: "fifth suggestion"},
+          {content: "f", description: "sixth suggestion"},
+          {content: "g", description: "seventh suggestion"},
+          {content: "h", description: "eigth suggestion"},
+          {content: "i", description: "ninth suggestion"},
+          {content: "j", description: "tenth suggestion"},
+        ]);
+        controller.stopSearch();
+      }
+    }
+  };
+
+  ExtensionSearchHandler.registerKeyword(keyword, mockExtension);
+
+  // Start an input session before testing MSG_INPUT_CHANGED.
+  ExtensionSearchHandler.handleSearch(keyword, `${keyword} `, () => {});
+
+  yield check_autocomplete({
+    search: `${keyword} #`,
+    searchParam: "enable-actions",
+    matches: [
+      makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} #`}),
+      makeExtensionMatch({keyword, content: `${keyword} a`, description: "first suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} b`, description: "second suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} c`, description: "third suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} d`, description: "fourth suggestion"}),
+      makeExtensionMatch({keyword, content: `${keyword} e`, description: "fifth suggestion"}),
+    ]
+  });
+
+  ExtensionSearchHandler.unregisterKeyword(keyword);
+  yield cleanup();
+});
--- a/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini
+++ b/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini
@@ -19,16 +19,17 @@ support-files =
 [test_avoid_stripping_to_empty_tokens.js]
 [test_casing.js]
 [test_do_not_trim.js]
 [test_download_embed_bookmarks.js]
 [test_dupe_urls.js]
 [test_empty_search.js]
 [test_enabled.js]
 [test_escape_self.js]
+[test_extension_matches.js]
 [test_ignore_protocol.js]
 [test_keyword_search.js]
 [test_keyword_search_actions.js]
 [test_keywords.js]
 [test_match_beginning.js]
 [test_multi_word_search.js]
 [test_query_url.js]
 [test_remote_tab_matches.js]
--- a/toolkit/content/widgets/autocomplete.xml
+++ b/toolkit/content/widgets/autocomplete.xml
@@ -2122,16 +2122,20 @@ extends="chrome://global/content/binding
               }
             } else if (action.type == "visiturl") {
               emphasiseUrl = false;
               displayUrl = this._unescapeUrl(action.params.url);
               title = displayUrl;
               titleLooksLikeUrl = true;
               let visitStr = this._stringBundle.GetStringFromName("visit");
               this._setUpDescription(this._actionText, visitStr, true);
+            } else if (action.type == "extension") {
+              let content = action.params.content;
+              displayUrl = content;
+              this._setUpDescription(this._actionText, content, true);
             }
           }
 
           if (!displayUrl) {
             let input = popup.input;
             let url = typeof(input.trimValue) == "function" ?
                       input.trimValue(originalUrl) :
                       originalUrl;