Mercurial > addons > firefox-addons > set-aside
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 })(); |