summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/ExtensionSearchHandler.sys.mjs
blob: a04e40b6c6f4634bc1871c4663bc548a2f9f17d6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
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;
  },
});