-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathbackground.js
348 lines (298 loc) · 11.1 KB
/
background.js
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
337
338
339
340
341
342
343
344
345
346
347
348
"use strict";
var browser = browser || chrome;//for Chrome
const CONTEXT_MENU_ITEM_ROOT_ID = "root";
const CONTEXT_MENU_ITEM_EMPTY_ID = "empty";
const CONTEXT_MENU_ITEM_UNTITLED = browser.i18n.getMessage("contextMenuItemUntitled");
const FOLDERS_GROUP_TITLES_SEP = " ▸ ";
const BOOKMARK_TREE_CHANGES_EVENTS = ["onCreated", "onRemoved", "onChanged", "onMoved", "onChildrenReordered"];
const BOOKMARK_TREE_CHANGES_DELAY = 1000;//ms
const PREF_FLAT_CONTEXT_MENU = "flatContextMenu";
let invalidBookmarklets = new Set();
//browser.runtime.lastError
class Bookmarklet{
constructor(source = "", title = ""){
this.source = source;
this.title = title;
}
}
class BookmarkletFolder{
constructor(children = [], title = ""){
this.children = children;
this.title = title;
}
}
class BookmarkletFolderGroup extends BookmarkletFolder{
constructor(folders = [], children = [], title = ""){
super(children, title);
this.folders = folders;
}
}
// export for action popup
window.Bookmarklet = Bookmarklet;
window.BookmarkletFolder = BookmarkletFolder;
window.BookmarkletFolderGroup = BookmarkletFolderGroup;
function logRejection(context, reason){
console.log(`${context} promise has been rejected: ${reason}`);
}
/**
* Create bookmarklet tree from given bookmark
* @returns {Bookmarklet|BookmarkletFolder|BookmarkletFolderGroup|null}
*/
function getBookmarkletTree(bookmark){
let title = bookmark.title || CONTEXT_MENU_ITEM_UNTITLED;
// If not a folder
if(!bookmark.children){
let url = bookmark.url;
if(url && url.startsWith("javascript:")){
let source;
try{
source = decodeURIComponent(url.slice(11))
}
catch(error){
// error instanceof URIError)
// Show this error only once
if(!invalidBookmarklets.has(url)){
invalidBookmarklets.add(url);
console.warn(`The bookmark "${title}" contains invalid percent-encoding sequence.`);
}
}
if(source){
return new Bookmarklet(source, title);
}
}
return null;
}
let children = bookmark.children.map(getBookmarkletTree).filter(value => value !== null);
if(children.length == 0){
return null;
}
let folder = new BookmarkletFolder(children, title);
// Nested folders
if(children.length == 1 && children[0] instanceof BookmarkletFolder){
let solitaryFolder = children[0];
// Already a group
if(solitaryFolder instanceof BookmarkletFolderGroup){
folder.children[0] = solitaryFolder.folders[0];// fix the tree
solitaryFolder.folders.unshift(folder);// group that folder too
solitaryFolder.title = solitaryFolder.folders.map(folder => folder.title).join(FOLDERS_GROUP_TITLES_SEP);
return solitaryFolder;
}
return new BookmarkletFolderGroup([folder, solitaryFolder], solitaryFolder.children, folder.title + FOLDERS_GROUP_TITLES_SEP + solitaryFolder.title);
}
return folder;
}
/**
* Handle context menu click event
* Will execute corresponding bookmarklet script
* @see executeBookmarkletSource()
*/
function contextMenuItemClick(bookmarklet, data, tab){
executeBookmarklet(bookmarklet, tab);
}
/**
* Execute bookmarklet script, without javascript:
*/
function executeBookmarklet(bookmarklet){
let code = bookmarklet.source;
//escaped code for injection in double quotes string (one line)
let dbQuotesEscapedCode = code.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\n").replace(/\r/g, "\\r");
// The code can be an Expression or a Statment(s). The last instruction value will be used as return value
// Undeclared variables are always global in sloppy mode
// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode
// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var
// Firefox allow to execute code in 2 different contexts: content script or page. But the last one could be forbidden by the CSP
// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Content_scripts#Using_eval()_in_content_scripts
const bookmarkletURI = `javascript:${encodeURIComponent(code)}`;
let contentScript = `
{
// Hide all global properties/function should not be available like .chrome or .browser WebExtension APIs
window.chrome = window.browser = undefined;
if(typeof chrome != "undefined") chrome = undefined;
if(typeof browser != "undefined") browser = undefined;
// Catch syntax error or any other API or thrown custom errors
let value;
// Use eval to get expression or statment result of the bookmarklet and define the sourceURL
try{
// Note: Chrome doesn't have a secured scope (no window.wrappedJSObject.eval vs window.eval) https://developer.chrome.com/extensions/content_scripts#execution-environment
// It's impossible to catch asynchronous errors (in listener, setTimeout, etc.) even with a global error handler
value = eval("${dbQuotesEscapedCode}\\n//# sourceURL=${bookmarkletURI}");
}catch(error){
// Remove stack part of internal (browser and extension)
const stack = error.stack.split("\\n").slice(0, -2).join("\\n");
window.wrappedJSObject.console.error(\`Bookmarklet error: $\{error.name\}: $\{error.message\}\\nStack trace:\\n$\{stack\}\`);
}
// Handle returned value asynchronously:
// Wait a navigation event.
// If there is no navigation event, load a blob document with the returned value as document source
if(value !== undefined){
const beforeUnload = event => {
window.removeEventListener("beforeunload", beforeUnload);
// If the event has been prevented (by other any script)
if(event.defaultPrevented){
return;
}
clearTimeout(timeout);
};
const timeout = () => {
const blob = new Blob([value], {type: "text/html;charset=utf-8"});
// use blob instead of doc.open() write() close() which works only for HTML doc, not for XML (SVG) docs
window.location = URL.createObjectURL(blob);
};
// listen unload event to wait a potential navigation event
window.addEventListener("beforeunload", beforeUnload);
// and set timeout to 100ms as fallback
const unloadTimeoutID = setTimeout(timeout, 100);
}
}
`;
/*
executeScript can be rejected for host mismatch: "Error: No window matching {"matchesHost":[]}"
or privileged URIs like: chrome://* or *://addons.mozilla.org/
(or script syntaxe)
See https://bugzilla.mozilla.org/show_bug.cgi?id=1310082
executeScript can be rejected for script error (syntax or privilege)
*/
return browser.tabs.executeScript({
code: contentScript,
runAt: "document_start"
}).catch(logRejection.bind(null, "bookmarklet execution"));
}
/**
* Create all context menu for the given bookmarklet tree
*/
function createAllContextMenuItems(bookmarklets, flat = false){
// Remove all remains context menu
browser.contextMenus.removeAll();
let bookmarkletsRoot = bookmarklets[0];
// add root context menu
let parentID = browser.contextMenus.create({
id: CONTEXT_MENU_ITEM_ROOT_ID,
title: browser.i18n.getMessage("contextMenuItemRoot"),
contexts: ["all"]
});
// If no bookmarklets
if(!bookmarkletsRoot || bookmarkletsRoot instanceof BookmarkletFolder && bookmarkletsRoot.children.length == 0){
browser.contextMenus.create({
id: CONTEXT_MENU_ITEM_EMPTY_ID,
title: browser.i18n.getMessage("contextMenuItemEmpty"),
parentId: parentID,
contexts: ["all"]
});
return;
}
// If only one folder (or folder group) list direcly its children
if(bookmarkletsRoot instanceof BookmarkletFolder){
createContextMenuItemsList(bookmarkletsRoot.children, parentID, flat);
} else {
createContextMenuItems(bookmarkletsRoot, parentID, flat);
}
}
/**
* Create a context menu entry for the given bookmarklet
*/
function createContextMenuItems(bookmarklet, parentContextMenuID, flat = false){
// If a folder of bookmarklets
if(bookmarklet instanceof BookmarkletFolder){
let parentID = parentContextMenuID;
let children = bookmarklet.children;
if(!flat){
parentID = browser.contextMenus.create({
title: bookmarklet.title,
parentId: parentContextMenuID,
contexts: ["all"]
});
}
createContextMenuItemsList(children, parentID, flat);
return;
}
browser.contextMenus.create({
title: bookmarklet.title,
parentId: parentContextMenuID,
onclick: contextMenuItemClick.bind(null, bookmarklet),
contexts: ["all"]
});
}
/**
* Create context menu entries for an array of bookmarklets
*/
function createContextMenuItemsList(bookmarklets, parentID, flat){
bookmarklets.forEach((bookmarklet, index, bookmarklets) => {
// if not first one and is folder or the previous is a folder
if(index > 0 && (bookmarklet instanceof BookmarkletFolder || bookmarklets[index - 1] instanceof BookmarkletFolder)){
browser.contextMenus.create({
type: "separator",
parentId: parentID,
contexts: ["all"]
});
}
createContextMenuItems(bookmarklet, parentID, flat)
});
}
/**
* Build or rebuild the context menu
* @returns Promise
*/
function updateContextMenu(){
return Promise.all([gettingBookmarkletTree, gettingFlatPref]).then(([bookmarklets, flat]) => createAllContextMenuItems(bookmarklets, flat), logRejection.bind(null, "update context menu"));
}
/**
* Get the bookmarklet tree
* @returns Promise
*/
function getBookmarkletTreePromise(){
return browser.bookmarks.getTree().then(bookmarks => [getBookmarkletTree(bookmarks[0])], logRejection.bind(null, "get bookmarklets tree"));
}
/**
* Bookmark tree events handler throttle / debounce function
*/
function updateDebounced(){
if(updateTimeoutID){
// Wait to update timeout
return;
}
updateTimeoutID = setTimeout(() => {
updateTimeoutID = 0;
gettingBookmarkletTree = getBookmarkletTreePromise();// update bookmarklet tree
updateContextMenu();
}, BOOKMARK_TREE_CHANGES_DELAY);
}
let updateTimeoutID = 0;
// Promise for flat context menu perference
var gettingFlatPref = browser.storage.local.get(PREF_FLAT_CONTEXT_MENU).then(result => Boolean(result[PREF_FLAT_CONTEXT_MENU]), logRejection.bind(null, "get preferences"));
// Promise for bookmarklet tree. The first time is set, enable browser action
var gettingBookmarkletTree = getBookmarkletTreePromise().then(bookmarklets => (browser.browserAction.enable(), bookmarklets));
// Inert context menu (disabled). Wait bookmarks retrival
browser.contextMenus.create({
id: CONTEXT_MENU_ITEM_ROOT_ID,
title: browser.i18n.getMessage("contextMenuItemRoot"),
contexts: ["all"],
enabled: false
});
// Disable browser action. Wait bookmarks retrival
browser.browserAction.disable();
// Add bookmark tree changes event listeners
// Don't handle onImportBegan and onImportEnded, but because we debounce (delay) update, it should be fine
{
const bookmarks = browser.bookmarks;
for(let event of BOOKMARK_TREE_CHANGES_EVENTS){
// Event not supported
if(typeof bookmarks[event] === "undefined" || typeof bookmarks[event].addListener !== "function"){
continue;
}
bookmarks[event].addListener(updateDebounced);
}
}
// Listen preferences changes
browser.storage.onChanged.addListener((changes, areaName) => {
// Ignore all others storage areas
if(areaName != "local"){
return;
}
let flatPrefChange = changes[PREF_FLAT_CONTEXT_MENU];
if(flatPrefChange && flatPrefChange.oldValue != flatPrefChange.newValue){
gettingFlatPref = Promise.resolve(Boolean(flatPrefChange.newValue));
update();
}
});
// Start
updateContextMenu();