comparison background.js @ 0:d13d59494613

Initial revision
author Guido Berhoerster <guido+set-aside@berhoerster.name>
date Sat, 17 Nov 2018 10:44:16 +0100
parents
children b0827360b8e4
comparison
equal deleted inserted replaced
-1:000000000000 0:d13d59494613
1 /*
2 * Copyright (C) 2018 Guido Berhoerster <guido+set-aside@berhoerster.name>
3 *
4 * This Source Code Form is subject to the terms of the Mozilla Public
5 * License, v. 2.0. If a copy of the MPL was not distributed with this
6 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7 */
8
9 'use strict';
10
11 const SUPPORTED_PROTOCOLS = ['https:', 'http:', 'ftp:'];
12 const GROUP_KEY_RE = /^collection:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/;
13 const FIREFOX_VERSION_RE = /^(\d+(?:\.\d+)*)(?:([ab]|pre)(\d+))?$/;
14 const FIREFOX_RELEASE_TYPES = {
15 'a': 'alpha',
16 'b': 'beta',
17 'pre': 'prerelease',
18 '': ''
19 }
20 const THUMBNAIL_WIDTH = 224;
21 const THUMBNAIL_HEIGHT = 128;
22
23 var tabCollectionsProxy;
24
25 function generateUuidV4String() {
26 let uuid = new Uint8Array(16);
27 window.crypto.getRandomValues(uuid);
28 uuid[6] = (uuid[6] & 0x0f) | 0x40;
29 uuid[8] = (uuid[8] & 0x3f) | 0x80;
30
31 let result = [];
32 for (let i = 0; i < uuid.length; i++) {
33 if (i == 4 || i == 6 || i == 8 || i == 10) {
34 result.push('-');
35 }
36 result.push(uuid[i].toString(16).padStart(2, '0'));
37 }
38
39 return result.join('');
40 }
41
42 function parseFirefoxVersion(firefoxVersionString) {
43 let [, versionString, releaseTypeAbbrev = '', releaseNumberString = '0'] =
44 FIREFOX_VERSION_RE.exec(firefoxVersionString);
45
46 let releaseType = FIREFOX_RELEASE_TYPES[releaseTypeAbbrev];
47
48 let releaseNumber = parseInt(releaseNumberString);
49 let [major = 0, minor = 0, patch = 0] = versionString.split('.')
50 .map(x => parseInt(x));
51
52 return {
53 major,
54 minor,
55 patch,
56 releaseType,
57 releaseNumber,
58 };
59 }
60
61 class Tab {
62 static deserialize(object) {
63 return new Tab(object);
64 }
65
66 constructor({url, title, uuid = generateUuidV4String(), favIconUrl = null,
67 thumbnailUrl = null}) {
68 this.uuid = uuid;
69 this.url = url;
70 this.title = title;
71 this.favIconUrl = favIconUrl;
72 this.thumbnailUrl = thumbnailUrl;
73 }
74
75 serialize() {
76 return Object.assign({}, this);
77 }
78 }
79
80 class TabCollection {
81 static deserialize(object) {
82 object.tabs = Array.from(object.tabs,
83 ([, tab]) => [tab.uuid, Tab.deserialize(tab)]);
84 return new TabCollection(object);
85 }
86
87 constructor({tabs, uuid = generateUuidV4String(), date = new Date()}) {
88 this.uuid = uuid;
89 this.date = new Date(date);
90 this.tabs = new Map();
91 // allow any type which allows iteration
92 for (let [, tab] of tabs) {
93 this.tabs.set(tab.uuid, tab);
94 }
95 }
96
97 serialize() {
98 let serializedTabs = [];
99 for (let [tabUuid, tab] of this.tabs) {
100 serializedTabs.push([tab.uuid, tab.serialize()]);
101 }
102 return {
103 uuid: this.uuid,
104 date: this.date.toJSON(),
105 tabs: serializedTabs
106 };
107 }
108 }
109
110 class TabCollectionsStorageProxy {
111 constructor() {
112 this.tabCollections = new Map();
113 this.ports = new Set();
114 this.browserVersion = undefined;
115 this.messageQueue = [];
116 this.isInitialized = false;
117
118 browser.runtime.onConnect.addListener(this.onConnect.bind(this));
119 }
120
121 async init() {
122 let browserInfo = await browser.runtime.getBrowserInfo();
123 this.browserVersion = parseFirefoxVersion(browserInfo.version);
124
125 // get all tab collections and deserialize them in a Map
126 let storageEntries = Object.entries(await browser.storage.sync.get())
127 .filter(([key, value]) => GROUP_KEY_RE.test(key))
128 .map(([key, tabCollection]) =>
129 [tabCollection.uuid, TabCollection.deserialize(tabCollection)]);
130 this.tabCollections = new Map(storageEntries);
131 console.log('tab collections from storage');
132 console.table(this.tabCollections);
133 console.groupEnd();
134 browser.storage.onChanged.addListener(this.onStorageChanged.bind(this));
135
136 this.isInitialized = true;
137
138 while (this.messageQueue.length > 0) {
139 let [message, port] = this.messageQueue.pop();
140 if (this.ports.has(port)) {
141 this.onMessage(message, port);
142 }
143 }
144 }
145
146 async createTabThumbnail(tabId) {
147 let captureUrl = await browser.tabs.captureTab(tabId);
148 let thumbnailUrl = await new Promise((resolve, reject) => {
149 let image = new Image();
150 image.addEventListener('load', ev => {
151 let canvas = document.createElement('canvas');
152 canvas.width = THUMBNAIL_WIDTH;
153 canvas.height = THUMBNAIL_HEIGHT;
154 let dWidth = canvas.width;
155 let dHeight = dWidth * (image.height / image.width);
156
157 let ctx = canvas.getContext('2d');
158 ctx.fillStyle = '#fff';
159 ctx.fillRect(0, 0, canvas.width, canvas.height);
160 ctx.drawImage(image, 0, 0, dWidth, dHeight);
161
162 resolve(canvas.toDataURL('image/jpeg', 0.75));
163 });
164 image.addEventListener('error', e => {
165 reject(e);
166 });
167 image.src = captureUrl;
168 });
169 return thumbnailUrl;
170 }
171
172 async createTabCollection(windowId) {
173 let browserTabs = await browser.tabs.query({
174 windowId,
175 hidden: false,
176 pinned:false
177 });
178
179 // sanity check to prevent saving tabs from incognito windows
180 if (browserTabs.length === 0 || browserTabs[0].incognito) {
181 return;
182 }
183
184 // filter out tabs which cannot be restored
185 browserTabs = browserTabs.filter(browserTab =>
186 SUPPORTED_PROTOCOLS.includes(new URL(browserTab.url).protocol));
187 if (browserTabs.length === 0) {
188 return;
189 }
190
191 let tabs = browserTabs.map(browserTab => {
192 let tab = new Tab({
193 url: browserTab.url,
194 title: browserTab.title,
195 favIconUrl: browserTab.favIconUrl
196 });
197 return [tab.uuid, tab];
198 });
199
200 // create empty tab which becomes the new active tab
201 await browser.tabs.create({active: true});
202
203 // capture tabs, return null for discarded tabs since they can only be
204 // captured after they have been restored, e.g. through user
205 // interaction, and thus might hang the capture process indefinetly
206 let thumbnails = await Promise.all(browserTabs.map(browserTab =>
207 !browserTab.discarded ?
208 this.createTabThumbnail(browserTab.id) : null));
209 for (let [, tab] of tabs) {
210 tab.thumbnailUrl = thumbnails.shift();
211 }
212
213 let tabCollection = new TabCollection({tabs});
214 console.log('created tab collection:', tabCollection);
215
216 // store tab collection
217 console.log('storing tab collection:', tabCollection);
218 await browser.storage.sync.set({
219 [`collection:${tabCollection.uuid}`]: tabCollection.serialize()
220 });
221
222 // remove tabs
223 await browser.tabs.remove(browserTabs.map(browserTab => browserTab.id));
224 }
225
226 async removeTab(tabCollectionUuid, tabUuid) {
227 console.log('removing tab %s from collection %s', tabUuid,
228 tabCollectionUuid);
229 let tabCollection = this.tabCollections.get(tabCollectionUuid);
230 // create shallow clone
231 let newTabCollection = new TabCollection(tabCollection);
232 newTabCollection.tabs.delete(tabUuid);
233 // remove tab collection if there are no more tabs
234 if (newTabCollection.tabs.size === 0) {
235 return this.removeTabCollection(tabCollectionUuid);
236 }
237 await browser.storage.sync.set({
238 [`collection:${tabCollectionUuid}`]: newTabCollection.serialize()
239 });
240 }
241
242 async restoreTab(tabCollectionUuid, tabUuid, windowId) {
243 console.log('restoring tab %s from collection %s in window %d', tabUuid,
244 tabCollectionUuid, windowId);
245 let tab = this.tabCollections.get(tabCollectionUuid).tabs.get(tabUuid);
246 let tabProperties = {
247 active: false,
248 url: tab.url,
249 windowId
250 };
251 if (this.browserVersion.major >= 63) {
252 tabProperties.discarded = true;
253 }
254 await browser.tabs.create(tabProperties);
255 await this.removeTab(tabCollectionUuid, tabUuid);
256 }
257
258 async removeTabCollection(tabCollectionUuid) {
259 console.log('removing tab collection %s', tabCollectionUuid);
260 await browser.storage.sync.remove(`collection:${tabCollectionUuid}`);
261 }
262
263 async restoreTabCollection(tabCollectionUuid, windowId) {
264 console.log('restoring tab collection %s in window %s',
265 tabCollectionUuid, windowId);
266 let tabProperties = {
267 active: false,
268 windowId
269 };
270 if (this.browserVersion.major >= 63) {
271 tabProperties.discarded = true;
272 }
273 let creatingTabs =
274 Array.from(this.tabCollections.get(tabCollectionUuid).tabs,
275 ([, tab]) => browser.tabs.create(Object.assign({
276 url: tab.url
277 }, tabProperties)));
278 await Promise.all(creatingTabs);
279 await this.removeTabCollection(tabCollectionUuid);
280 }
281
282 onStorageChanged(changes, areaName) {
283 if (areaName !== 'sync') {
284 return;
285 }
286
287 console.group('sync storage area changed:', changes);
288 console.table(Object.entries(changes)[0][1])
289 console.groupEnd();
290
291 let [key, {oldValue, newValue}] = Object.entries(changes)[0];
292 if (!GROUP_KEY_RE.test(key)) {
293 return;
294 }
295
296 let tabCollectionUuid = key.replace('collection:', '');
297 if (typeof oldValue === 'undefined') {
298 // a new collection was created
299 let newTabCollection = TabCollection.deserialize(newValue);
300 this.tabCollections.set(tabCollectionUuid, newTabCollection);
301
302 this.broadcastMessage({
303 type: 'tabCollectionCreated',
304 tabCollection: newTabCollection
305 });
306 } else if (typeof newValue === 'undefined') {
307 // a collection has been removed
308 this.tabCollections.delete(tabCollectionUuid);
309
310 this.broadcastMessage({
311 type: 'tabCollectionRemoved',
312 tabCollectionUuid
313 });
314 } else {
315 // a collection has changed
316 let newTabCollection = TabCollection.deserialize(newValue);
317 this.tabCollections.set(tabCollectionUuid, newTabCollection);
318
319 this.broadcastMessage({
320 type: 'tabCollectionChanged',
321 tabCollection: newTabCollection
322 });
323 }
324 }
325
326 broadcastMessage(message) {
327 for (let port of this.ports) {
328 port.postMessage(message);
329 }
330 }
331
332 onConnect(port) {
333 console.log('port connected:', port)
334 this.ports.add(port);
335 port.onMessage.addListener(this.onMessage.bind(this));
336 port.onDisconnect.addListener(this.onDisconnect.bind(this));
337 }
338
339 onDisconnect(port) {
340 if (port.error) {
341 console.log(`port connection error: ${port.error}\n`);
342 }
343 console.log('port disconnected:', port);
344 this.ports.delete(port);
345 }
346
347 onMessage(message, port) {
348 if (!this.isInitialized) {
349 console.log('queued message', message, 'from port', port);
350 this.messageQueue.push([message, port]);
351 return;
352 }
353
354 console.log('received message', message, 'on port', port);
355 switch (message.type) {
356 case 'getTabCollections':
357 port.postMessage({
358 type: 'tabCollections',
359 tabCollections: this.tabCollections
360 });
361 break;
362 case 'removeTab':
363 this.removeTab(message.tabCollectionUuid, message.tabUuid);
364 break;
365 case 'restoreTab':
366 this.restoreTab(message.tabCollectionUuid, message.tabUuid,
367 message.windowId);
368 break;
369 case 'removeTabCollection':
370 this.removeTabCollection(message.tabCollectionUuid);
371 break;
372 case 'restoreTabCollection':
373 this.restoreTabCollection(message.tabCollectionUuid,
374 message.windowId);
375 break;
376 }
377 }
378 }
379
380 // browser action context menu entry for opening the sidebar
381 browser.menus.create({
382 contexts: ['browser_action'],
383 onclick: (info, tab) => browser.sidebarAction.open(),
384 title: browser.i18n.getMessage('showTabsMenuItem')
385 });
386
387 // disable the browser action for new incognito tabs
388 browser.tabs.onCreated.addListener(tab => {
389 if (tab.incognito) {
390 // this does not work, it seems that the browser action is re-enabled
391 // on every update
392 browser.browserAction.disable(tab.id);
393 }
394 });
395
396 (async () => {
397 // disable the browser action for existing incognito tabs
398 let tabs = await browser.tabs.query({});
399 await Promise.all(tabs.filter(tab => tab.incognito)
400 .map(tab => browser.browserAction.disable(tab.id)))
401
402 tabCollectionsProxy = new TabCollectionsStorageProxy();
403 await tabCollectionsProxy.init();
404
405 browser.browserAction.onClicked.addListener(async targetTab => {
406 // prevent browser action from being activated while a collection is
407 // being created
408 let tabs = await browser.tabs.query({windowId: targetTab.windowId});
409 await Promise.all(tabs.map(tab =>
410 browser.browserAction.disable(tab.id)));
411
412 try {
413 await tabCollectionsProxy.createTabCollection(targetTab.windowId);
414 } catch (e) {
415 tabs = await browser.tabs.query({windowId: targetTab.windowId});
416 await Promise.all(tabs.map(tab =>
417 browser.browserAction.enable(tab.id)));
418 throw e
419 }
420
421 tabs = await browser.tabs.query({windowId: targetTab.windowId});
422 await Promise.all(tabs.map(tab =>
423 browser.browserAction.enable(tab.id)));
424 });
425 })();