Source code

Revision control

Other Tools

1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
3
* You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5
"use strict";
6
7
var EXPORTED_SYMBOLS = ["ExtensionSearchHandler"];
8
9
// Used to keep track of all of the registered keywords, where each keyword is
10
// mapped to a KeywordInfo instance.
11
let gKeywordMap = new Map();
12
13
// Used to keep track of the active input session.
14
let gActiveInputSession = null;
15
16
// Used to keep track of who has control over the active suggestion callback
17
// so older callbacks can be ignored. The callback ID should increment whenever
18
// the input changes or the input session ends.
19
let gCurrentCallbackID = 0;
20
21
// Handles keeping track of information associated to the registered keyword.
22
class KeywordInfo {
23
constructor(extension, description) {
24
this._extension = extension;
25
this._description = description;
26
}
27
28
get description() {
29
return this._description;
30
}
31
32
set description(desc) {
33
this._description = desc;
34
}
35
36
get extension() {
37
return this._extension;
38
}
39
}
40
41
// Responsible for handling communication between the extension and the urlbar.
42
class InputSession {
43
constructor(keyword, extension) {
44
this._keyword = keyword;
45
this._extension = extension;
46
this._suggestionsCallback = null;
47
this._searchFinishedCallback = null;
48
}
49
50
get keyword() {
51
return this._keyword;
52
}
53
54
addSuggestions(suggestions) {
55
if (this._suggestionsCallback) {
56
this._suggestionsCallback(suggestions);
57
}
58
}
59
60
start(eventName) {
61
this._extension.emit(eventName);
62
}
63
64
update(eventName, text, suggestionsCallback, searchFinishedCallback) {
65
// Check to see if an existing input session needs to be ended first.
66
if (this._searchFinishedCallback) {
67
this._searchFinishedCallback();
68
}
69
this._searchFinishedCallback = searchFinishedCallback;
70
this._suggestionsCallback = suggestionsCallback;
71
this._extension.emit(eventName, text, ++gCurrentCallbackID);
72
}
73
74
cancel(eventName) {
75
if (this._searchFinishedCallback) {
76
this._searchFinishedCallback();
77
this._searchFinishedCallback = null;
78
this._suggestionsCallback = null;
79
}
80
this._extension.emit(eventName);
81
}
82
83
end(eventName, text, disposition) {
84
if (this._searchFinishedCallback) {
85
this._searchFinishedCallback();
86
this._searchFinishedCallback = null;
87
this._suggestionsCallback = null;
88
}
89
this._extension.emit(eventName, text, disposition);
90
}
91
}
92
93
var ExtensionSearchHandler = Object.freeze({
94
MSG_INPUT_STARTED: "webext-omnibox-input-started",
95
MSG_INPUT_CHANGED: "webext-omnibox-input-changed",
96
MSG_INPUT_ENTERED: "webext-omnibox-input-entered",
97
MSG_INPUT_CANCELLED: "webext-omnibox-input-cancelled",
98
99
/**
100
* Registers a keyword.
101
*
102
* @param {string} keyword The keyword to register.
103
* @param {Extension} extension The extension registering the keyword.
104
*/
105
registerKeyword(keyword, extension) {
106
if (gKeywordMap.has(keyword)) {
107
throw new Error(
108
`The keyword provided is already registered: "${keyword}"`
109
);
110
}
111
gKeywordMap.set(keyword, new KeywordInfo(extension, extension.name));
112
},
113
114
/**
115
* Unregisters a keyword.
116
*
117
* @param {string} keyword The keyword to unregister.
118
*/
119
unregisterKeyword(keyword) {
120
if (!gKeywordMap.has(keyword)) {
121
throw new Error(`The keyword provided is not registered: "${keyword}"`);
122
}
123
gActiveInputSession = null;
124
gKeywordMap.delete(keyword);
125
},
126
127
/**
128
* Checks if a keyword is registered.
129
*
130
* @param {string} keyword The word to check.
131
* @return {boolean} true if the word is a registered keyword.
132
*/
133
isKeywordRegistered(keyword) {
134
return gKeywordMap.has(keyword);
135
},
136
137
/**
138
* @return {boolean} true if there is an active input session.
139
*/
140
hasActiveInputSession() {
141
return gActiveInputSession != null;
142
},
143
144
/**
145
* @param {string} keyword The keyword to look up.
146
* @return {string} the description to use for the heuristic result.
147
*/
148
getDescription(keyword) {
149
if (!gKeywordMap.has(keyword)) {
150
throw new Error(`The keyword provided is not registered: "${keyword}"`);
151
}
152
return gKeywordMap.get(keyword).description;
153
},
154
155
/**
156
* Sets the default suggestion for the registered keyword. The suggestion's
157
* description will be used for the comment in the heuristic result.
158
*
159
* @param {string} keyword The keyword.
160
* @param {string} description The description to use for the heuristic result.
161
*/
162
setDefaultSuggestion(keyword, { description }) {
163
if (!gKeywordMap.has(keyword)) {
164
throw new Error(`The keyword provided is not registered: "${keyword}"`);
165
}
166
gKeywordMap.get(keyword).description = description;
167
},
168
169
/**
170
* Adds suggestions for the registered keyword. This function will throw if
171
* the keyword provided is not registered or active, or if the callback ID
172
* provided is no longer equal to the active callback ID.
173
*
174
* @param {string} keyword The keyword.
175
* @param {integer} id The ID of the suggestion callback.
176
* @param {Array<Object>} suggestions An array of suggestions to provide to the urlbar.
177
*/
178
addSuggestions(keyword, id, suggestions) {
179
if (!gKeywordMap.has(keyword)) {
180
throw new Error(`The keyword provided is not registered: "${keyword}"`);
181
}
182
183
if (!gActiveInputSession || gActiveInputSession.keyword != keyword) {
184
throw new Error(
185
`The keyword provided is not apart of an active input session: "${keyword}"`
186
);
187
}
188
189
if (id != gCurrentCallbackID) {
190
throw new Error(
191
`The callback is no longer active for the keyword provided: "${keyword}"`
192
);
193
}
194
195
gActiveInputSession.addSuggestions(suggestions);
196
},
197
198
/**
199
* Called when the input in the urlbar begins with `<keyword><space>`.
200
*
201
* If the keyword is inactive, MSG_INPUT_STARTED is emitted and the
202
* keyword is marked as active. If the keyword is followed by any text,
203
* MSG_INPUT_CHANGED is fired with the current callback ID that can be
204
* used to provide suggestions to the urlbar while the callback ID is active.
205
* The callback is invalidated when either the input changes or the urlbar blurs.
206
*
207
* @param {object} data An object that contains
208
* {string} keyword The keyword to handle.
209
* {string} text The search text in the urlbar.
210
* {boolean} inPrivateWindow privateness of window search
211
* is occuring in.
212
* @param {Function} callback The callback used to provide search suggestions.
213
* @return {Promise} promise that resolves when the current search is complete.
214
*/
215
handleSearch(data, callback) {
216
let { keyword, text } = data;
217
let keywordInfo = gKeywordMap.get(keyword);
218
if (!keywordInfo) {
219
throw new Error(`The keyword provided is not registered: "${keyword}"`);
220
}
221
222
let { extension } = keywordInfo;
223
if (data.inPrivateWindow && !extension.privateBrowsingAllowed) {
224
return Promise.resolve(false);
225
}
226
227
if (gActiveInputSession && gActiveInputSession.keyword != keyword) {
228
throw new Error("A different input session is already ongoing");
229
}
230
231
if (!text || !text.startsWith(`${keyword} `)) {
232
throw new Error(`The text provided must start with: "${keyword} "`);
233
}
234
235
if (!callback) {
236
throw new Error("A callback must be provided");
237
}
238
239
// The search text in the urlbar currently starts with <keyword><space>, and
240
// we only want the text that follows.
241
text = text.substring(keyword.length + 1);
242
243
// We fire MSG_INPUT_STARTED once we have <keyword><space>, and only fire
244
// MSG_INPUT_CHANGED when we have text to process. This is different from Chrome's
245
// behavior, which always fires MSG_INPUT_STARTED right before MSG_INPUT_CHANGED
246
// first fires, but this is a bug in Chrome according to https://crbug.com/258911.
247
if (!gActiveInputSession) {
248
gActiveInputSession = new InputSession(keyword, extension);
249
gActiveInputSession.start(this.MSG_INPUT_STARTED);
250
251
// Resolve early if there is no text to process. There can be text to process when
252
// the input starts if the user copy/pastes the text into the urlbar.
253
if (!text.length) {
254
return Promise.resolve();
255
}
256
}
257
258
return new Promise(resolve => {
259
gActiveInputSession.update(
260
this.MSG_INPUT_CHANGED,
261
text,
262
callback,
263
resolve
264
);
265
});
266
},
267
268
/**
269
* Called when the user clicks on a suggestion that was added by
270
* an extension. MSG_INPUT_ENTERED is emitted to the extension with
271
* the keyword, the current search string, and info about how the
272
* the search should be handled. This ends the active input session.
273
*
274
* @param {string} keyword The keyword associated to the suggestion.
275
* @param {string} text The search text in the urlbar.
276
* @param {string} where How the page should be opened. Accepted values are:
277
* "current": open the page in the same tab.
278
* "tab": open the page in a new foreground tab.
279
* "tabshifted": open the page in a new background tab.
280
*/
281
handleInputEntered(keyword, text, where) {
282
if (!gKeywordMap.has(keyword)) {
283
throw new Error(`The keyword provided is not registered: "${keyword}"`);
284
}
285
286
if (!gActiveInputSession) {
287
throw new Error("There is no active input session");
288
}
289
290
if (gActiveInputSession && gActiveInputSession.keyword != keyword) {
291
throw new Error("A different input session is already ongoing");
292
}
293
294
if (!text || !text.startsWith(`${keyword} `)) {
295
throw new Error(`The text provided must start with: "${keyword} "`);
296
}
297
298
let dispositionMap = {
299
current: "currentTab",
300
tab: "newForegroundTab",
301
tabshifted: "newBackgroundTab",
302
};
303
let disposition = dispositionMap[where];
304
305
if (!disposition) {
306
throw new Error(`Invalid "where" argument: ${where}`);
307
}
308
309
// The search text in the urlbar currently starts with <keyword><space>, and
310
// we only want to send the text that follows.
311
text = text.substring(keyword.length + 1);
312
313
gActiveInputSession.end(this.MSG_INPUT_ENTERED, text, disposition);
314
gActiveInputSession = null;
315
},
316
317
/**
318
* If the user has ended the keyword input session without accepting the input,
319
* MSG_INPUT_CANCELLED is emitted and the input session is ended.
320
*/
321
handleInputCancelled() {
322
if (!gActiveInputSession) {
323
throw new Error("There is no active input session");
324
}
325
gActiveInputSession.cancel(this.MSG_INPUT_CANCELLED);
326
gActiveInputSession = null;
327
},
328
});