|
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 this.EXPORTED_SYMBOLS = ["PageThumbs", "PageThumbsStorage"]; |
|
8 |
|
9 const Cu = Components.utils; |
|
10 const Cc = Components.classes; |
|
11 const Ci = Components.interfaces; |
|
12 |
|
13 const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; |
|
14 const PREF_STORAGE_VERSION = "browser.pagethumbnails.storage_version"; |
|
15 const LATEST_STORAGE_VERSION = 3; |
|
16 |
|
17 const EXPIRATION_MIN_CHUNK_SIZE = 50; |
|
18 const EXPIRATION_INTERVAL_SECS = 3600; |
|
19 |
|
20 // If a request for a thumbnail comes in and we find one that is "stale" |
|
21 // (or don't find one at all) we automatically queue a request to generate a |
|
22 // new one. |
|
23 const MAX_THUMBNAIL_AGE_SECS = 172800; // 2 days == 60*60*24*2 == 172800 secs. |
|
24 |
|
25 /** |
|
26 * Name of the directory in the profile that contains the thumbnails. |
|
27 */ |
|
28 const THUMBNAIL_DIRECTORY = "thumbnails"; |
|
29 |
|
30 /** |
|
31 * The default background color for page thumbnails. |
|
32 */ |
|
33 const THUMBNAIL_BG_COLOR = "#fff"; |
|
34 |
|
35 Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); |
|
36 Cu.import("resource://gre/modules/osfile/_PromiseWorker.jsm", this); |
|
37 Cu.import("resource://gre/modules/Promise.jsm", this); |
|
38 Cu.import("resource://gre/modules/osfile.jsm", this); |
|
39 |
|
40 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", |
|
41 "resource://gre/modules/NetUtil.jsm"); |
|
42 |
|
43 XPCOMUtils.defineLazyModuleGetter(this, "Services", |
|
44 "resource://gre/modules/Services.jsm"); |
|
45 |
|
46 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", |
|
47 "resource://gre/modules/FileUtils.jsm"); |
|
48 |
|
49 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", |
|
50 "resource://gre/modules/PlacesUtils.jsm"); |
|
51 |
|
52 XPCOMUtils.defineLazyServiceGetter(this, "gUpdateTimerManager", |
|
53 "@mozilla.org/updates/timer-manager;1", "nsIUpdateTimerManager"); |
|
54 |
|
55 XPCOMUtils.defineLazyGetter(this, "gCryptoHash", function () { |
|
56 return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); |
|
57 }); |
|
58 |
|
59 XPCOMUtils.defineLazyGetter(this, "gUnicodeConverter", function () { |
|
60 let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] |
|
61 .createInstance(Ci.nsIScriptableUnicodeConverter); |
|
62 converter.charset = 'utf8'; |
|
63 return converter; |
|
64 }); |
|
65 |
|
66 XPCOMUtils.defineLazyModuleGetter(this, "Task", |
|
67 "resource://gre/modules/Task.jsm"); |
|
68 XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", |
|
69 "resource://gre/modules/Deprecated.jsm"); |
|
70 XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", |
|
71 "resource://gre/modules/AsyncShutdown.jsm"); |
|
72 |
|
73 /** |
|
74 * Utilities for dealing with promises and Task.jsm |
|
75 */ |
|
76 const TaskUtils = { |
|
77 /** |
|
78 * Add logging to a promise. |
|
79 * |
|
80 * @param {Promise} promise |
|
81 * @return {Promise} A promise behaving as |promise|, but with additional |
|
82 * logging in case of uncaught error. |
|
83 */ |
|
84 captureErrors: function captureErrors(promise) { |
|
85 return promise.then( |
|
86 null, |
|
87 function onError(reason) { |
|
88 Cu.reportError("Uncaught asynchronous error: " + reason + " at\n" |
|
89 + reason.stack + "\n"); |
|
90 throw reason; |
|
91 } |
|
92 ); |
|
93 }, |
|
94 |
|
95 /** |
|
96 * Spawn a new Task from a generator. |
|
97 * |
|
98 * This function behaves as |Task.spawn|, with the exception that it |
|
99 * adds logging in case of uncaught error. For more information, see |
|
100 * the documentation of |Task.jsm|. |
|
101 * |
|
102 * @param {generator} gen Some generator. |
|
103 * @return {Promise} A promise built from |gen|, with the same semantics |
|
104 * as |Task.spawn(gen)|. |
|
105 */ |
|
106 spawn: function spawn(gen) { |
|
107 return this.captureErrors(Task.spawn(gen)); |
|
108 }, |
|
109 /** |
|
110 * Read the bytes from a blob, asynchronously. |
|
111 * |
|
112 * @return {Promise} |
|
113 * @resolve {ArrayBuffer} In case of success, the bytes contained in the blob. |
|
114 * @reject {DOMError} In case of error, the underlying DOMError. |
|
115 */ |
|
116 readBlob: function readBlob(blob) { |
|
117 let deferred = Promise.defer(); |
|
118 let reader = Cc["@mozilla.org/files/filereader;1"].createInstance(Ci.nsIDOMFileReader); |
|
119 reader.onloadend = function onloadend() { |
|
120 if (reader.readyState != Ci.nsIDOMFileReader.DONE) { |
|
121 deferred.reject(reader.error); |
|
122 } else { |
|
123 deferred.resolve(reader.result); |
|
124 } |
|
125 }; |
|
126 reader.readAsArrayBuffer(blob); |
|
127 return deferred.promise; |
|
128 } |
|
129 }; |
|
130 |
|
131 |
|
132 |
|
133 |
|
134 /** |
|
135 * Singleton providing functionality for capturing web page thumbnails and for |
|
136 * accessing them if already cached. |
|
137 */ |
|
138 this.PageThumbs = { |
|
139 _initialized: false, |
|
140 |
|
141 /** |
|
142 * The calculated width and height of the thumbnails. |
|
143 */ |
|
144 _thumbnailWidth : 0, |
|
145 _thumbnailHeight : 0, |
|
146 |
|
147 /** |
|
148 * The scheme to use for thumbnail urls. |
|
149 */ |
|
150 get scheme() "moz-page-thumb", |
|
151 |
|
152 /** |
|
153 * The static host to use for thumbnail urls. |
|
154 */ |
|
155 get staticHost() "thumbnail", |
|
156 |
|
157 /** |
|
158 * The thumbnails' image type. |
|
159 */ |
|
160 get contentType() "image/png", |
|
161 |
|
162 init: function PageThumbs_init() { |
|
163 if (!this._initialized) { |
|
164 this._initialized = true; |
|
165 PlacesUtils.history.addObserver(PageThumbsHistoryObserver, false); |
|
166 |
|
167 // Migrate the underlying storage, if needed. |
|
168 PageThumbsStorageMigrator.migrate(); |
|
169 PageThumbsExpiration.init(); |
|
170 } |
|
171 }, |
|
172 |
|
173 uninit: function PageThumbs_uninit() { |
|
174 if (this._initialized) { |
|
175 this._initialized = false; |
|
176 PlacesUtils.history.removeObserver(PageThumbsHistoryObserver); |
|
177 } |
|
178 }, |
|
179 |
|
180 /** |
|
181 * Gets the thumbnail image's url for a given web page's url. |
|
182 * @param aUrl The web page's url that is depicted in the thumbnail. |
|
183 * @return The thumbnail image's url. |
|
184 */ |
|
185 getThumbnailURL: function PageThumbs_getThumbnailURL(aUrl) { |
|
186 return this.scheme + "://" + this.staticHost + |
|
187 "?url=" + encodeURIComponent(aUrl); |
|
188 }, |
|
189 |
|
190 /** |
|
191 * Gets the path of the thumbnail file for a given web page's |
|
192 * url. This file may or may not exist depending on whether the |
|
193 * thumbnail has been captured or not. |
|
194 * |
|
195 * @param aUrl The web page's url. |
|
196 * @return The path of the thumbnail file. |
|
197 */ |
|
198 getThumbnailPath: function PageThumbs_getThumbnailPath(aUrl) { |
|
199 return PageThumbsStorage.getFilePathForURL(aUrl); |
|
200 }, |
|
201 |
|
202 /** |
|
203 * Captures a thumbnail for the given window. |
|
204 * @param aWindow The DOM window to capture a thumbnail from. |
|
205 * @param aCallback The function to be called when the thumbnail has been |
|
206 * captured. The first argument will be the data stream |
|
207 * containing the image data. |
|
208 */ |
|
209 capture: function PageThumbs_capture(aWindow, aCallback) { |
|
210 if (!this._prefEnabled()) { |
|
211 return; |
|
212 } |
|
213 |
|
214 let canvas = this._createCanvas(); |
|
215 this.captureToCanvas(aWindow, canvas); |
|
216 |
|
217 // Fetch the canvas data on the next event loop tick so that we allow |
|
218 // some event processing in between drawing to the canvas and encoding |
|
219 // its data. We want to block the UI as short as possible. See bug 744100. |
|
220 Services.tm.currentThread.dispatch(function () { |
|
221 canvas.mozFetchAsStream(aCallback, this.contentType); |
|
222 }.bind(this), Ci.nsIThread.DISPATCH_NORMAL); |
|
223 }, |
|
224 |
|
225 |
|
226 /** |
|
227 * Captures a thumbnail for the given window. |
|
228 * |
|
229 * @param aWindow The DOM window to capture a thumbnail from. |
|
230 * @return {Promise} |
|
231 * @resolve {Blob} The thumbnail, as a Blob. |
|
232 */ |
|
233 captureToBlob: function PageThumbs_captureToBlob(aWindow) { |
|
234 if (!this._prefEnabled()) { |
|
235 return null; |
|
236 } |
|
237 |
|
238 let canvas = this._createCanvas(); |
|
239 this.captureToCanvas(aWindow, canvas); |
|
240 |
|
241 let deferred = Promise.defer(); |
|
242 let type = this.contentType; |
|
243 // Fetch the canvas data on the next event loop tick so that we allow |
|
244 // some event processing in between drawing to the canvas and encoding |
|
245 // its data. We want to block the UI as short as possible. See bug 744100. |
|
246 canvas.toBlob(function asBlob(blob) { |
|
247 deferred.resolve(blob, type); |
|
248 }); |
|
249 return deferred.promise; |
|
250 }, |
|
251 |
|
252 /** |
|
253 * Captures a thumbnail from a given window and draws it to the given canvas. |
|
254 * @param aWindow The DOM window to capture a thumbnail from. |
|
255 * @param aCanvas The canvas to draw to. |
|
256 */ |
|
257 captureToCanvas: function PageThumbs_captureToCanvas(aWindow, aCanvas) { |
|
258 let telemetryCaptureTime = new Date(); |
|
259 this._captureToCanvas(aWindow, aCanvas); |
|
260 let telemetry = Services.telemetry; |
|
261 telemetry.getHistogramById("FX_THUMBNAILS_CAPTURE_TIME_MS") |
|
262 .add(new Date() - telemetryCaptureTime); |
|
263 }, |
|
264 |
|
265 // The background thumbnail service captures to canvas but doesn't want to |
|
266 // participate in this service's telemetry, which is why this method exists. |
|
267 _captureToCanvas: function PageThumbs__captureToCanvas(aWindow, aCanvas) { |
|
268 let [sw, sh, scale] = this._determineCropSize(aWindow, aCanvas); |
|
269 let ctx = aCanvas.getContext("2d"); |
|
270 |
|
271 // Scale the canvas accordingly. |
|
272 ctx.save(); |
|
273 ctx.scale(scale, scale); |
|
274 |
|
275 try { |
|
276 // Draw the window contents to the canvas. |
|
277 ctx.drawWindow(aWindow, 0, 0, sw, sh, THUMBNAIL_BG_COLOR, |
|
278 ctx.DRAWWINDOW_DO_NOT_FLUSH); |
|
279 } catch (e) { |
|
280 // We couldn't draw to the canvas for some reason. |
|
281 } |
|
282 |
|
283 ctx.restore(); |
|
284 }, |
|
285 |
|
286 /** |
|
287 * Captures a thumbnail for the given browser and stores it to the cache. |
|
288 * @param aBrowser The browser to capture a thumbnail for. |
|
289 * @param aCallback The function to be called when finished (optional). |
|
290 */ |
|
291 captureAndStore: function PageThumbs_captureAndStore(aBrowser, aCallback) { |
|
292 if (!this._prefEnabled()) { |
|
293 return; |
|
294 } |
|
295 |
|
296 let url = aBrowser.currentURI.spec; |
|
297 let channel = aBrowser.docShell.currentDocumentChannel; |
|
298 let originalURL = channel.originalURI.spec; |
|
299 |
|
300 // see if this was an error response. |
|
301 let wasError = this._isChannelErrorResponse(channel); |
|
302 |
|
303 TaskUtils.spawn((function task() { |
|
304 let isSuccess = true; |
|
305 try { |
|
306 let blob = yield this.captureToBlob(aBrowser.contentWindow); |
|
307 let buffer = yield TaskUtils.readBlob(blob); |
|
308 yield this._store(originalURL, url, buffer, wasError); |
|
309 } catch (_) { |
|
310 isSuccess = false; |
|
311 } |
|
312 if (aCallback) { |
|
313 aCallback(isSuccess); |
|
314 } |
|
315 }).bind(this)); |
|
316 }, |
|
317 |
|
318 /** |
|
319 * Checks if an existing thumbnail for the specified URL is either missing |
|
320 * or stale, and if so, captures and stores it. Once the thumbnail is stored, |
|
321 * an observer service notification will be sent, so consumers should observe |
|
322 * such notifications if they want to be notified of an updated thumbnail. |
|
323 * |
|
324 * @param aBrowser The content window of this browser will be captured. |
|
325 * @param aCallback The function to be called when finished (optional). |
|
326 */ |
|
327 captureAndStoreIfStale: function PageThumbs_captureAndStoreIfStale(aBrowser, aCallback) { |
|
328 let url = aBrowser.currentURI.spec; |
|
329 PageThumbsStorage.isFileRecentForURL(url).then(recent => { |
|
330 if (!recent.ok && |
|
331 // Careful, the call to PageThumbsStorage is async, so the browser may |
|
332 // have navigated away from the URL or even closed. |
|
333 aBrowser.currentURI && |
|
334 aBrowser.currentURI.spec == url) { |
|
335 this.captureAndStore(aBrowser, aCallback); |
|
336 } else if (aCallback) { |
|
337 aCallback(true); |
|
338 } |
|
339 }, err => { |
|
340 if (aCallback) |
|
341 aCallback(false); |
|
342 }); |
|
343 }, |
|
344 |
|
345 /** |
|
346 * Stores data to disk for the given URLs. |
|
347 * |
|
348 * NB: The background thumbnail service calls this, too. |
|
349 * |
|
350 * @param aOriginalURL The URL with which the capture was initiated. |
|
351 * @param aFinalURL The URL to which aOriginalURL ultimately resolved. |
|
352 * @param aData An ArrayBuffer containing the image data. |
|
353 * @param aNoOverwrite If true and files for the URLs already exist, the files |
|
354 * will not be overwritten. |
|
355 * @return {Promise} |
|
356 */ |
|
357 _store: function PageThumbs__store(aOriginalURL, aFinalURL, aData, aNoOverwrite) { |
|
358 return TaskUtils.spawn(function () { |
|
359 let telemetryStoreTime = new Date(); |
|
360 yield PageThumbsStorage.writeData(aFinalURL, aData, aNoOverwrite); |
|
361 Services.telemetry.getHistogramById("FX_THUMBNAILS_STORE_TIME_MS") |
|
362 .add(new Date() - telemetryStoreTime); |
|
363 |
|
364 Services.obs.notifyObservers(null, "page-thumbnail:create", aFinalURL); |
|
365 // We've been redirected. Create a copy of the current thumbnail for |
|
366 // the redirect source. We need to do this because: |
|
367 // |
|
368 // 1) Users can drag any kind of links onto the newtab page. If those |
|
369 // links redirect to a different URL then we want to be able to |
|
370 // provide thumbnails for both of them. |
|
371 // |
|
372 // 2) The newtab page should actually display redirect targets, only. |
|
373 // Because of bug 559175 this information can get lost when using |
|
374 // Sync and therefore also redirect sources appear on the newtab |
|
375 // page. We also want thumbnails for those. |
|
376 if (aFinalURL != aOriginalURL) { |
|
377 yield PageThumbsStorage.copy(aFinalURL, aOriginalURL, aNoOverwrite); |
|
378 Services.obs.notifyObservers(null, "page-thumbnail:create", aOriginalURL); |
|
379 } |
|
380 }); |
|
381 }, |
|
382 |
|
383 /** |
|
384 * Register an expiration filter. |
|
385 * |
|
386 * When thumbnails are going to expire, each registered filter is asked for a |
|
387 * list of thumbnails to keep. |
|
388 * |
|
389 * The filter (if it is a callable) or its filterForThumbnailExpiration method |
|
390 * (if the filter is an object) is called with a single argument. The |
|
391 * argument is a callback function. The filter must call the callback |
|
392 * function and pass it an array of zero or more URLs. (It may do so |
|
393 * asynchronously.) Thumbnails for those URLs will be except from expiration. |
|
394 * |
|
395 * @param aFilter callable, or object with filterForThumbnailExpiration method |
|
396 */ |
|
397 addExpirationFilter: function PageThumbs_addExpirationFilter(aFilter) { |
|
398 PageThumbsExpiration.addFilter(aFilter); |
|
399 }, |
|
400 |
|
401 /** |
|
402 * Unregister an expiration filter. |
|
403 * @param aFilter A filter that was previously passed to addExpirationFilter. |
|
404 */ |
|
405 removeExpirationFilter: function PageThumbs_removeExpirationFilter(aFilter) { |
|
406 PageThumbsExpiration.removeFilter(aFilter); |
|
407 }, |
|
408 |
|
409 /** |
|
410 * Determines the crop size for a given content window. |
|
411 * @param aWindow The content window. |
|
412 * @param aCanvas The target canvas. |
|
413 * @return An array containing width, height and scale. |
|
414 */ |
|
415 _determineCropSize: function PageThumbs_determineCropSize(aWindow, aCanvas) { |
|
416 let utils = aWindow.QueryInterface(Ci.nsIInterfaceRequestor) |
|
417 .getInterface(Ci.nsIDOMWindowUtils); |
|
418 let sbWidth = {}, sbHeight = {}; |
|
419 |
|
420 try { |
|
421 utils.getScrollbarSize(false, sbWidth, sbHeight); |
|
422 } catch (e) { |
|
423 // This might fail if the window does not have a presShell. |
|
424 Cu.reportError("Unable to get scrollbar size in _determineCropSize."); |
|
425 sbWidth.value = sbHeight.value = 0; |
|
426 } |
|
427 |
|
428 // Even in RTL mode, scrollbars are always on the right. |
|
429 // So there's no need to determine a left offset. |
|
430 let sw = aWindow.innerWidth - sbWidth.value; |
|
431 let sh = aWindow.innerHeight - sbHeight.value; |
|
432 |
|
433 let {width: thumbnailWidth, height: thumbnailHeight} = aCanvas; |
|
434 let scale = Math.min(Math.max(thumbnailWidth / sw, thumbnailHeight / sh), 1); |
|
435 let scaledWidth = sw * scale; |
|
436 let scaledHeight = sh * scale; |
|
437 |
|
438 if (scaledHeight > thumbnailHeight) |
|
439 sh -= Math.floor(Math.abs(scaledHeight - thumbnailHeight) * scale); |
|
440 |
|
441 if (scaledWidth > thumbnailWidth) |
|
442 sw -= Math.floor(Math.abs(scaledWidth - thumbnailWidth) * scale); |
|
443 |
|
444 return [sw, sh, scale]; |
|
445 }, |
|
446 |
|
447 /** |
|
448 * Creates a new hidden canvas element. |
|
449 * @param aWindow The document of this window will be used to create the |
|
450 * canvas. If not given, the hidden window will be used. |
|
451 * @return The newly created canvas. |
|
452 */ |
|
453 _createCanvas: function PageThumbs_createCanvas(aWindow) { |
|
454 let doc = (aWindow || Services.appShell.hiddenDOMWindow).document; |
|
455 let canvas = doc.createElementNS(HTML_NAMESPACE, "canvas"); |
|
456 canvas.mozOpaque = true; |
|
457 canvas.mozImageSmoothingEnabled = true; |
|
458 let [thumbnailWidth, thumbnailHeight] = this._getThumbnailSize(); |
|
459 canvas.width = thumbnailWidth; |
|
460 canvas.height = thumbnailHeight; |
|
461 return canvas; |
|
462 }, |
|
463 |
|
464 /** |
|
465 * Calculates the thumbnail size based on current desktop's dimensions. |
|
466 * @return The calculated thumbnail size or a default if unable to calculate. |
|
467 */ |
|
468 _getThumbnailSize: function PageThumbs_getThumbnailSize() { |
|
469 if (!this._thumbnailWidth || !this._thumbnailHeight) { |
|
470 let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"] |
|
471 .getService(Ci.nsIScreenManager); |
|
472 let left = {}, top = {}, width = {}, height = {}; |
|
473 screenManager.primaryScreen.GetRectDisplayPix(left, top, width, height); |
|
474 this._thumbnailWidth = Math.round(width.value / 3); |
|
475 this._thumbnailHeight = Math.round(height.value / 3); |
|
476 } |
|
477 return [this._thumbnailWidth, this._thumbnailHeight]; |
|
478 }, |
|
479 |
|
480 /** |
|
481 * Given a channel, returns true if it should be considered an "error |
|
482 * response", false otherwise. |
|
483 */ |
|
484 _isChannelErrorResponse: function(channel) { |
|
485 // No valid document channel sounds like an error to me! |
|
486 if (!channel) |
|
487 return true; |
|
488 if (!(channel instanceof Ci.nsIHttpChannel)) |
|
489 // it might be FTP etc, so assume it's ok. |
|
490 return false; |
|
491 try { |
|
492 return !channel.requestSucceeded; |
|
493 } catch (_) { |
|
494 // not being able to determine success is surely failure! |
|
495 return true; |
|
496 } |
|
497 }, |
|
498 |
|
499 _prefEnabled: function PageThumbs_prefEnabled() { |
|
500 try { |
|
501 return !Services.prefs.getBoolPref("browser.pagethumbnails.capturing_disabled"); |
|
502 } |
|
503 catch (e) { |
|
504 return true; |
|
505 } |
|
506 }, |
|
507 }; |
|
508 |
|
509 this.PageThumbsStorage = { |
|
510 // The path for the storage |
|
511 _path: null, |
|
512 get path() { |
|
513 if (!this._path) { |
|
514 this._path = OS.Path.join(OS.Constants.Path.localProfileDir, THUMBNAIL_DIRECTORY); |
|
515 } |
|
516 return this._path; |
|
517 }, |
|
518 |
|
519 ensurePath: function Storage_ensurePath() { |
|
520 // Create the directory (ignore any error if the directory |
|
521 // already exists). As all writes are done from the PageThumbsWorker |
|
522 // thread, which serializes its operations, this ensures that |
|
523 // future operations can proceed without having to check whether |
|
524 // the directory exists. |
|
525 return PageThumbsWorker.post("makeDir", |
|
526 [this.path, {ignoreExisting: true}]).then( |
|
527 null, |
|
528 function onError(aReason) { |
|
529 Components.utils.reportError("Could not create thumbnails directory" + aReason); |
|
530 }); |
|
531 }, |
|
532 |
|
533 getLeafNameForURL: function Storage_getLeafNameForURL(aURL) { |
|
534 if (typeof aURL != "string") { |
|
535 throw new TypeError("Expecting a string"); |
|
536 } |
|
537 let hash = this._calculateMD5Hash(aURL); |
|
538 return hash + ".png"; |
|
539 }, |
|
540 |
|
541 getFilePathForURL: function Storage_getFilePathForURL(aURL) { |
|
542 return OS.Path.join(this.path, this.getLeafNameForURL(aURL)); |
|
543 }, |
|
544 |
|
545 /** |
|
546 * Write the contents of a thumbnail, off the main thread. |
|
547 * |
|
548 * @param {string} aURL The url for which to store a thumbnail. |
|
549 * @param {ArrayBuffer} aData The data to store in the thumbnail, as |
|
550 * an ArrayBuffer. This array buffer is neutered and cannot be |
|
551 * reused after the copy. |
|
552 * @param {boolean} aNoOverwrite If true and the thumbnail's file already |
|
553 * exists, the file will not be overwritten. |
|
554 * |
|
555 * @return {Promise} |
|
556 */ |
|
557 writeData: function Storage_writeData(aURL, aData, aNoOverwrite) { |
|
558 let path = this.getFilePathForURL(aURL); |
|
559 this.ensurePath(); |
|
560 aData = new Uint8Array(aData); |
|
561 let msg = [ |
|
562 path, |
|
563 aData, |
|
564 { |
|
565 tmpPath: path + ".tmp", |
|
566 bytes: aData.byteLength, |
|
567 noOverwrite: aNoOverwrite, |
|
568 flush: false /*thumbnails do not require the level of guarantee provided by flush*/ |
|
569 }]; |
|
570 return PageThumbsWorker.post("writeAtomic", msg, |
|
571 msg /*we don't want that message garbage-collected, |
|
572 as OS.Shared.Type.void_t.in_ptr.toMsg uses C-level |
|
573 memory tricks to enforce zero-copy*/). |
|
574 then(null, this._eatNoOverwriteError(aNoOverwrite)); |
|
575 }, |
|
576 |
|
577 /** |
|
578 * Copy a thumbnail, off the main thread. |
|
579 * |
|
580 * @param {string} aSourceURL The url of the thumbnail to copy. |
|
581 * @param {string} aTargetURL The url of the target thumbnail. |
|
582 * @param {boolean} aNoOverwrite If true and the target file already exists, |
|
583 * the file will not be overwritten. |
|
584 * |
|
585 * @return {Promise} |
|
586 */ |
|
587 copy: function Storage_copy(aSourceURL, aTargetURL, aNoOverwrite) { |
|
588 this.ensurePath(); |
|
589 let sourceFile = this.getFilePathForURL(aSourceURL); |
|
590 let targetFile = this.getFilePathForURL(aTargetURL); |
|
591 let options = { noOverwrite: aNoOverwrite }; |
|
592 return PageThumbsWorker.post("copy", [sourceFile, targetFile, options]). |
|
593 then(null, this._eatNoOverwriteError(aNoOverwrite)); |
|
594 }, |
|
595 |
|
596 /** |
|
597 * Remove a single thumbnail, off the main thread. |
|
598 * |
|
599 * @return {Promise} |
|
600 */ |
|
601 remove: function Storage_remove(aURL) { |
|
602 return PageThumbsWorker.post("remove", [this.getFilePathForURL(aURL)]); |
|
603 }, |
|
604 |
|
605 /** |
|
606 * Remove all thumbnails, off the main thread. |
|
607 * |
|
608 * @return {Promise} |
|
609 */ |
|
610 wipe: Task.async(function* Storage_wipe() { |
|
611 // |
|
612 // This operation may be launched during shutdown, so we need to |
|
613 // take a few precautions to ensure that: |
|
614 // |
|
615 // 1. it is not interrupted by shutdown, in which case we |
|
616 // could be leaving privacy-sensitive files on disk; |
|
617 // 2. it is not launched too late during shutdown, in which |
|
618 // case this could cause shutdown freezes (see bug 1005487, |
|
619 // which will eventually be fixed by bug 965309) |
|
620 // |
|
621 |
|
622 let blocker = () => promise; |
|
623 |
|
624 // The following operation will rise an error if we have already |
|
625 // reached profileBeforeChange, in which case it is too late |
|
626 // to clear the thumbnail wipe. |
|
627 AsyncShutdown.profileBeforeChange.addBlocker( |
|
628 "PageThumbs: removing all thumbnails", |
|
629 blocker); |
|
630 |
|
631 // Start the work only now that `profileBeforeChange` has had |
|
632 // a chance to throw an error. |
|
633 |
|
634 let promise = PageThumbsWorker.post("wipe", [this.path]); |
|
635 try { |
|
636 yield promise; |
|
637 } finally { |
|
638 // Generally, we will be done much before profileBeforeChange, |
|
639 // so let's not hoard blockers. |
|
640 if ("removeBlocker" in AsyncShutdown.profileBeforeChange) { |
|
641 // `removeBlocker` was added with bug 985655. In the interest |
|
642 // of backporting, let's degrade gracefully if `removeBlocker` |
|
643 // doesn't exist. |
|
644 AsyncShutdown.profileBeforeChange.removeBlocker(blocker); |
|
645 } |
|
646 } |
|
647 }), |
|
648 |
|
649 fileExistsForURL: function Storage_fileExistsForURL(aURL) { |
|
650 return PageThumbsWorker.post("exists", [this.getFilePathForURL(aURL)]); |
|
651 }, |
|
652 |
|
653 isFileRecentForURL: function Storage_isFileRecentForURL(aURL) { |
|
654 return PageThumbsWorker.post("isFileRecent", |
|
655 [this.getFilePathForURL(aURL), |
|
656 MAX_THUMBNAIL_AGE_SECS]); |
|
657 }, |
|
658 |
|
659 _calculateMD5Hash: function Storage_calculateMD5Hash(aValue) { |
|
660 let hash = gCryptoHash; |
|
661 let value = gUnicodeConverter.convertToByteArray(aValue); |
|
662 |
|
663 hash.init(hash.MD5); |
|
664 hash.update(value, value.length); |
|
665 return this._convertToHexString(hash.finish(false)); |
|
666 }, |
|
667 |
|
668 _convertToHexString: function Storage_convertToHexString(aData) { |
|
669 let hex = ""; |
|
670 for (let i = 0; i < aData.length; i++) |
|
671 hex += ("0" + aData.charCodeAt(i).toString(16)).slice(-2); |
|
672 return hex; |
|
673 }, |
|
674 |
|
675 /** |
|
676 * For functions that take a noOverwrite option, OS.File throws an error if |
|
677 * the target file exists and noOverwrite is true. We don't consider that an |
|
678 * error, and we don't want such errors propagated. |
|
679 * |
|
680 * @param {aNoOverwrite} The noOverwrite option used in the OS.File operation. |
|
681 * |
|
682 * @return {function} A function that should be passed as the second argument |
|
683 * to then() (the `onError` argument). |
|
684 */ |
|
685 _eatNoOverwriteError: function Storage__eatNoOverwriteError(aNoOverwrite) { |
|
686 return function onError(err) { |
|
687 if (!aNoOverwrite || |
|
688 !(err instanceof OS.File.Error) || |
|
689 !err.becauseExists) { |
|
690 throw err; |
|
691 } |
|
692 }; |
|
693 }, |
|
694 |
|
695 // Deprecated, please do not use |
|
696 getFileForURL: function Storage_getFileForURL_DEPRECATED(aURL) { |
|
697 Deprecated.warning("PageThumbs.getFileForURL is deprecated. Please use PageThumbs.getFilePathForURL and OS.File", |
|
698 "https://developer.mozilla.org/docs/JavaScript_OS.File"); |
|
699 // Note: Once this method has been removed, we can get rid of the dependency towards FileUtils |
|
700 return new FileUtils.File(PageThumbsStorage.getFilePathForURL(aURL)); |
|
701 } |
|
702 }; |
|
703 |
|
704 let PageThumbsStorageMigrator = { |
|
705 get currentVersion() { |
|
706 try { |
|
707 return Services.prefs.getIntPref(PREF_STORAGE_VERSION); |
|
708 } catch (e) { |
|
709 // The pref doesn't exist, yet. Return version 0. |
|
710 return 0; |
|
711 } |
|
712 }, |
|
713 |
|
714 set currentVersion(aVersion) { |
|
715 Services.prefs.setIntPref(PREF_STORAGE_VERSION, aVersion); |
|
716 }, |
|
717 |
|
718 migrate: function Migrator_migrate() { |
|
719 let version = this.currentVersion; |
|
720 |
|
721 // Storage version 1 never made it to beta. |
|
722 // At the time of writing only Windows had (ProfD != ProfLD) and we |
|
723 // needed to move thumbnails from the roaming profile to the locale |
|
724 // one so that they're not needlessly included in backups and/or |
|
725 // written via SMB. |
|
726 |
|
727 // Storage version 2 also never made it to beta. |
|
728 // The thumbnail folder structure has been changed and old thumbnails |
|
729 // were not migrated. Instead, we just renamed the current folder to |
|
730 // "<name>-old" and will remove it later. |
|
731 |
|
732 if (version < 3) { |
|
733 this.migrateToVersion3(); |
|
734 } |
|
735 |
|
736 this.currentVersion = LATEST_STORAGE_VERSION; |
|
737 }, |
|
738 |
|
739 /** |
|
740 * Bug 239254 added support for having the disk cache and thumbnail |
|
741 * directories on a local path (i.e. ~/.cache/) under Linux. We'll first |
|
742 * try to move the old thumbnails to their new location. If that's not |
|
743 * possible (because ProfD might be on a different file system than |
|
744 * ProfLD) we'll just discard them. |
|
745 * |
|
746 * @param {string*} local The path to the local profile directory. |
|
747 * Used for testing. Default argument is good for all non-testing uses. |
|
748 * @param {string*} roaming The path to the roaming profile directory. |
|
749 * Used for testing. Default argument is good for all non-testing uses. |
|
750 */ |
|
751 migrateToVersion3: function Migrator_migrateToVersion3( |
|
752 local = OS.Constants.Path.localProfileDir, |
|
753 roaming = OS.Constants.Path.profileDir) { |
|
754 PageThumbsWorker.post( |
|
755 "moveOrDeleteAllThumbnails", |
|
756 [OS.Path.join(roaming, THUMBNAIL_DIRECTORY), |
|
757 OS.Path.join(local, THUMBNAIL_DIRECTORY)] |
|
758 ); |
|
759 } |
|
760 }; |
|
761 |
|
762 let PageThumbsExpiration = { |
|
763 _filters: [], |
|
764 |
|
765 init: function Expiration_init() { |
|
766 gUpdateTimerManager.registerTimer("browser-cleanup-thumbnails", this, |
|
767 EXPIRATION_INTERVAL_SECS); |
|
768 }, |
|
769 |
|
770 addFilter: function Expiration_addFilter(aFilter) { |
|
771 this._filters.push(aFilter); |
|
772 }, |
|
773 |
|
774 removeFilter: function Expiration_removeFilter(aFilter) { |
|
775 let index = this._filters.indexOf(aFilter); |
|
776 if (index > -1) |
|
777 this._filters.splice(index, 1); |
|
778 }, |
|
779 |
|
780 notify: function Expiration_notify(aTimer) { |
|
781 let urls = []; |
|
782 let filtersToWaitFor = this._filters.length; |
|
783 |
|
784 let expire = function expire() { |
|
785 this.expireThumbnails(urls); |
|
786 }.bind(this); |
|
787 |
|
788 // No registered filters. |
|
789 if (!filtersToWaitFor) { |
|
790 expire(); |
|
791 return; |
|
792 } |
|
793 |
|
794 function filterCallback(aURLs) { |
|
795 urls = urls.concat(aURLs); |
|
796 if (--filtersToWaitFor == 0) |
|
797 expire(); |
|
798 } |
|
799 |
|
800 for (let filter of this._filters) { |
|
801 if (typeof filter == "function") |
|
802 filter(filterCallback) |
|
803 else |
|
804 filter.filterForThumbnailExpiration(filterCallback); |
|
805 } |
|
806 }, |
|
807 |
|
808 expireThumbnails: function Expiration_expireThumbnails(aURLsToKeep) { |
|
809 let path = this.path; |
|
810 let keep = [PageThumbsStorage.getLeafNameForURL(url) for (url of aURLsToKeep)]; |
|
811 let msg = [ |
|
812 PageThumbsStorage.path, |
|
813 keep, |
|
814 EXPIRATION_MIN_CHUNK_SIZE |
|
815 ]; |
|
816 |
|
817 return PageThumbsWorker.post( |
|
818 "expireFilesInDirectory", |
|
819 msg |
|
820 ); |
|
821 } |
|
822 }; |
|
823 |
|
824 /** |
|
825 * Interface to a dedicated thread handling I/O |
|
826 */ |
|
827 |
|
828 let PageThumbsWorker = (function() { |
|
829 let worker = new PromiseWorker("resource://gre/modules/PageThumbsWorker.js", |
|
830 OS.Shared.LOG.bind("PageThumbs")); |
|
831 return { |
|
832 post: function post(...args) { |
|
833 let promise = worker.post.apply(worker, args); |
|
834 return promise.then( |
|
835 null, |
|
836 function onError(error) { |
|
837 // Decode any serialized error |
|
838 if (error instanceof PromiseWorker.WorkerError) { |
|
839 throw OS.File.Error.fromMsg(error.data); |
|
840 } else { |
|
841 throw error; |
|
842 } |
|
843 } |
|
844 ); |
|
845 } |
|
846 }; |
|
847 })(); |
|
848 |
|
849 let PageThumbsHistoryObserver = { |
|
850 onDeleteURI: function Thumbnails_onDeleteURI(aURI, aGUID) { |
|
851 PageThumbsStorage.remove(aURI.spec); |
|
852 }, |
|
853 |
|
854 onClearHistory: function Thumbnails_onClearHistory() { |
|
855 PageThumbsStorage.wipe(); |
|
856 }, |
|
857 |
|
858 onTitleChanged: function () {}, |
|
859 onBeginUpdateBatch: function () {}, |
|
860 onEndUpdateBatch: function () {}, |
|
861 onVisit: function () {}, |
|
862 onPageChanged: function () {}, |
|
863 onDeleteVisits: function () {}, |
|
864 |
|
865 QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver]) |
|
866 }; |