michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this file, michael@0: * You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: this.EXPORTED_SYMBOLS = ["PageThumbs", "PageThumbsStorage"]; michael@0: michael@0: const Cu = Components.utils; michael@0: const Cc = Components.classes; michael@0: const Ci = Components.interfaces; michael@0: michael@0: const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; michael@0: const PREF_STORAGE_VERSION = "browser.pagethumbnails.storage_version"; michael@0: const LATEST_STORAGE_VERSION = 3; michael@0: michael@0: const EXPIRATION_MIN_CHUNK_SIZE = 50; michael@0: const EXPIRATION_INTERVAL_SECS = 3600; michael@0: michael@0: // If a request for a thumbnail comes in and we find one that is "stale" michael@0: // (or don't find one at all) we automatically queue a request to generate a michael@0: // new one. michael@0: const MAX_THUMBNAIL_AGE_SECS = 172800; // 2 days == 60*60*24*2 == 172800 secs. michael@0: michael@0: /** michael@0: * Name of the directory in the profile that contains the thumbnails. michael@0: */ michael@0: const THUMBNAIL_DIRECTORY = "thumbnails"; michael@0: michael@0: /** michael@0: * The default background color for page thumbnails. michael@0: */ michael@0: const THUMBNAIL_BG_COLOR = "#fff"; michael@0: michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); michael@0: Cu.import("resource://gre/modules/osfile/_PromiseWorker.jsm", this); michael@0: Cu.import("resource://gre/modules/Promise.jsm", this); michael@0: Cu.import("resource://gre/modules/osfile.jsm", this); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", michael@0: "resource://gre/modules/NetUtil.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "Services", michael@0: "resource://gre/modules/Services.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", michael@0: "resource://gre/modules/FileUtils.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", michael@0: "resource://gre/modules/PlacesUtils.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyServiceGetter(this, "gUpdateTimerManager", michael@0: "@mozilla.org/updates/timer-manager;1", "nsIUpdateTimerManager"); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "gCryptoHash", function () { michael@0: return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "gUnicodeConverter", function () { michael@0: let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] michael@0: .createInstance(Ci.nsIScriptableUnicodeConverter); michael@0: converter.charset = 'utf8'; michael@0: return converter; michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "Task", michael@0: "resource://gre/modules/Task.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", michael@0: "resource://gre/modules/Deprecated.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", michael@0: "resource://gre/modules/AsyncShutdown.jsm"); michael@0: michael@0: /** michael@0: * Utilities for dealing with promises and Task.jsm michael@0: */ michael@0: const TaskUtils = { michael@0: /** michael@0: * Add logging to a promise. michael@0: * michael@0: * @param {Promise} promise michael@0: * @return {Promise} A promise behaving as |promise|, but with additional michael@0: * logging in case of uncaught error. michael@0: */ michael@0: captureErrors: function captureErrors(promise) { michael@0: return promise.then( michael@0: null, michael@0: function onError(reason) { michael@0: Cu.reportError("Uncaught asynchronous error: " + reason + " at\n" michael@0: + reason.stack + "\n"); michael@0: throw reason; michael@0: } michael@0: ); michael@0: }, michael@0: michael@0: /** michael@0: * Spawn a new Task from a generator. michael@0: * michael@0: * This function behaves as |Task.spawn|, with the exception that it michael@0: * adds logging in case of uncaught error. For more information, see michael@0: * the documentation of |Task.jsm|. michael@0: * michael@0: * @param {generator} gen Some generator. michael@0: * @return {Promise} A promise built from |gen|, with the same semantics michael@0: * as |Task.spawn(gen)|. michael@0: */ michael@0: spawn: function spawn(gen) { michael@0: return this.captureErrors(Task.spawn(gen)); michael@0: }, michael@0: /** michael@0: * Read the bytes from a blob, asynchronously. michael@0: * michael@0: * @return {Promise} michael@0: * @resolve {ArrayBuffer} In case of success, the bytes contained in the blob. michael@0: * @reject {DOMError} In case of error, the underlying DOMError. michael@0: */ michael@0: readBlob: function readBlob(blob) { michael@0: let deferred = Promise.defer(); michael@0: let reader = Cc["@mozilla.org/files/filereader;1"].createInstance(Ci.nsIDOMFileReader); michael@0: reader.onloadend = function onloadend() { michael@0: if (reader.readyState != Ci.nsIDOMFileReader.DONE) { michael@0: deferred.reject(reader.error); michael@0: } else { michael@0: deferred.resolve(reader.result); michael@0: } michael@0: }; michael@0: reader.readAsArrayBuffer(blob); michael@0: return deferred.promise; michael@0: } michael@0: }; michael@0: michael@0: michael@0: michael@0: michael@0: /** michael@0: * Singleton providing functionality for capturing web page thumbnails and for michael@0: * accessing them if already cached. michael@0: */ michael@0: this.PageThumbs = { michael@0: _initialized: false, michael@0: michael@0: /** michael@0: * The calculated width and height of the thumbnails. michael@0: */ michael@0: _thumbnailWidth : 0, michael@0: _thumbnailHeight : 0, michael@0: michael@0: /** michael@0: * The scheme to use for thumbnail urls. michael@0: */ michael@0: get scheme() "moz-page-thumb", michael@0: michael@0: /** michael@0: * The static host to use for thumbnail urls. michael@0: */ michael@0: get staticHost() "thumbnail", michael@0: michael@0: /** michael@0: * The thumbnails' image type. michael@0: */ michael@0: get contentType() "image/png", michael@0: michael@0: init: function PageThumbs_init() { michael@0: if (!this._initialized) { michael@0: this._initialized = true; michael@0: PlacesUtils.history.addObserver(PageThumbsHistoryObserver, false); michael@0: michael@0: // Migrate the underlying storage, if needed. michael@0: PageThumbsStorageMigrator.migrate(); michael@0: PageThumbsExpiration.init(); michael@0: } michael@0: }, michael@0: michael@0: uninit: function PageThumbs_uninit() { michael@0: if (this._initialized) { michael@0: this._initialized = false; michael@0: PlacesUtils.history.removeObserver(PageThumbsHistoryObserver); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Gets the thumbnail image's url for a given web page's url. michael@0: * @param aUrl The web page's url that is depicted in the thumbnail. michael@0: * @return The thumbnail image's url. michael@0: */ michael@0: getThumbnailURL: function PageThumbs_getThumbnailURL(aUrl) { michael@0: return this.scheme + "://" + this.staticHost + michael@0: "?url=" + encodeURIComponent(aUrl); michael@0: }, michael@0: michael@0: /** michael@0: * Gets the path of the thumbnail file for a given web page's michael@0: * url. This file may or may not exist depending on whether the michael@0: * thumbnail has been captured or not. michael@0: * michael@0: * @param aUrl The web page's url. michael@0: * @return The path of the thumbnail file. michael@0: */ michael@0: getThumbnailPath: function PageThumbs_getThumbnailPath(aUrl) { michael@0: return PageThumbsStorage.getFilePathForURL(aUrl); michael@0: }, michael@0: michael@0: /** michael@0: * Captures a thumbnail for the given window. michael@0: * @param aWindow The DOM window to capture a thumbnail from. michael@0: * @param aCallback The function to be called when the thumbnail has been michael@0: * captured. The first argument will be the data stream michael@0: * containing the image data. michael@0: */ michael@0: capture: function PageThumbs_capture(aWindow, aCallback) { michael@0: if (!this._prefEnabled()) { michael@0: return; michael@0: } michael@0: michael@0: let canvas = this._createCanvas(); michael@0: this.captureToCanvas(aWindow, canvas); michael@0: michael@0: // Fetch the canvas data on the next event loop tick so that we allow michael@0: // some event processing in between drawing to the canvas and encoding michael@0: // its data. We want to block the UI as short as possible. See bug 744100. michael@0: Services.tm.currentThread.dispatch(function () { michael@0: canvas.mozFetchAsStream(aCallback, this.contentType); michael@0: }.bind(this), Ci.nsIThread.DISPATCH_NORMAL); michael@0: }, michael@0: michael@0: michael@0: /** michael@0: * Captures a thumbnail for the given window. michael@0: * michael@0: * @param aWindow The DOM window to capture a thumbnail from. michael@0: * @return {Promise} michael@0: * @resolve {Blob} The thumbnail, as a Blob. michael@0: */ michael@0: captureToBlob: function PageThumbs_captureToBlob(aWindow) { michael@0: if (!this._prefEnabled()) { michael@0: return null; michael@0: } michael@0: michael@0: let canvas = this._createCanvas(); michael@0: this.captureToCanvas(aWindow, canvas); michael@0: michael@0: let deferred = Promise.defer(); michael@0: let type = this.contentType; michael@0: // Fetch the canvas data on the next event loop tick so that we allow michael@0: // some event processing in between drawing to the canvas and encoding michael@0: // its data. We want to block the UI as short as possible. See bug 744100. michael@0: canvas.toBlob(function asBlob(blob) { michael@0: deferred.resolve(blob, type); michael@0: }); michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: /** michael@0: * Captures a thumbnail from a given window and draws it to the given canvas. michael@0: * @param aWindow The DOM window to capture a thumbnail from. michael@0: * @param aCanvas The canvas to draw to. michael@0: */ michael@0: captureToCanvas: function PageThumbs_captureToCanvas(aWindow, aCanvas) { michael@0: let telemetryCaptureTime = new Date(); michael@0: this._captureToCanvas(aWindow, aCanvas); michael@0: let telemetry = Services.telemetry; michael@0: telemetry.getHistogramById("FX_THUMBNAILS_CAPTURE_TIME_MS") michael@0: .add(new Date() - telemetryCaptureTime); michael@0: }, michael@0: michael@0: // The background thumbnail service captures to canvas but doesn't want to michael@0: // participate in this service's telemetry, which is why this method exists. michael@0: _captureToCanvas: function PageThumbs__captureToCanvas(aWindow, aCanvas) { michael@0: let [sw, sh, scale] = this._determineCropSize(aWindow, aCanvas); michael@0: let ctx = aCanvas.getContext("2d"); michael@0: michael@0: // Scale the canvas accordingly. michael@0: ctx.save(); michael@0: ctx.scale(scale, scale); michael@0: michael@0: try { michael@0: // Draw the window contents to the canvas. michael@0: ctx.drawWindow(aWindow, 0, 0, sw, sh, THUMBNAIL_BG_COLOR, michael@0: ctx.DRAWWINDOW_DO_NOT_FLUSH); michael@0: } catch (e) { michael@0: // We couldn't draw to the canvas for some reason. michael@0: } michael@0: michael@0: ctx.restore(); michael@0: }, michael@0: michael@0: /** michael@0: * Captures a thumbnail for the given browser and stores it to the cache. michael@0: * @param aBrowser The browser to capture a thumbnail for. michael@0: * @param aCallback The function to be called when finished (optional). michael@0: */ michael@0: captureAndStore: function PageThumbs_captureAndStore(aBrowser, aCallback) { michael@0: if (!this._prefEnabled()) { michael@0: return; michael@0: } michael@0: michael@0: let url = aBrowser.currentURI.spec; michael@0: let channel = aBrowser.docShell.currentDocumentChannel; michael@0: let originalURL = channel.originalURI.spec; michael@0: michael@0: // see if this was an error response. michael@0: let wasError = this._isChannelErrorResponse(channel); michael@0: michael@0: TaskUtils.spawn((function task() { michael@0: let isSuccess = true; michael@0: try { michael@0: let blob = yield this.captureToBlob(aBrowser.contentWindow); michael@0: let buffer = yield TaskUtils.readBlob(blob); michael@0: yield this._store(originalURL, url, buffer, wasError); michael@0: } catch (_) { michael@0: isSuccess = false; michael@0: } michael@0: if (aCallback) { michael@0: aCallback(isSuccess); michael@0: } michael@0: }).bind(this)); michael@0: }, michael@0: michael@0: /** michael@0: * Checks if an existing thumbnail for the specified URL is either missing michael@0: * or stale, and if so, captures and stores it. Once the thumbnail is stored, michael@0: * an observer service notification will be sent, so consumers should observe michael@0: * such notifications if they want to be notified of an updated thumbnail. michael@0: * michael@0: * @param aBrowser The content window of this browser will be captured. michael@0: * @param aCallback The function to be called when finished (optional). michael@0: */ michael@0: captureAndStoreIfStale: function PageThumbs_captureAndStoreIfStale(aBrowser, aCallback) { michael@0: let url = aBrowser.currentURI.spec; michael@0: PageThumbsStorage.isFileRecentForURL(url).then(recent => { michael@0: if (!recent.ok && michael@0: // Careful, the call to PageThumbsStorage is async, so the browser may michael@0: // have navigated away from the URL or even closed. michael@0: aBrowser.currentURI && michael@0: aBrowser.currentURI.spec == url) { michael@0: this.captureAndStore(aBrowser, aCallback); michael@0: } else if (aCallback) { michael@0: aCallback(true); michael@0: } michael@0: }, err => { michael@0: if (aCallback) michael@0: aCallback(false); michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Stores data to disk for the given URLs. michael@0: * michael@0: * NB: The background thumbnail service calls this, too. michael@0: * michael@0: * @param aOriginalURL The URL with which the capture was initiated. michael@0: * @param aFinalURL The URL to which aOriginalURL ultimately resolved. michael@0: * @param aData An ArrayBuffer containing the image data. michael@0: * @param aNoOverwrite If true and files for the URLs already exist, the files michael@0: * will not be overwritten. michael@0: * @return {Promise} michael@0: */ michael@0: _store: function PageThumbs__store(aOriginalURL, aFinalURL, aData, aNoOverwrite) { michael@0: return TaskUtils.spawn(function () { michael@0: let telemetryStoreTime = new Date(); michael@0: yield PageThumbsStorage.writeData(aFinalURL, aData, aNoOverwrite); michael@0: Services.telemetry.getHistogramById("FX_THUMBNAILS_STORE_TIME_MS") michael@0: .add(new Date() - telemetryStoreTime); michael@0: michael@0: Services.obs.notifyObservers(null, "page-thumbnail:create", aFinalURL); michael@0: // We've been redirected. Create a copy of the current thumbnail for michael@0: // the redirect source. We need to do this because: michael@0: // michael@0: // 1) Users can drag any kind of links onto the newtab page. If those michael@0: // links redirect to a different URL then we want to be able to michael@0: // provide thumbnails for both of them. michael@0: // michael@0: // 2) The newtab page should actually display redirect targets, only. michael@0: // Because of bug 559175 this information can get lost when using michael@0: // Sync and therefore also redirect sources appear on the newtab michael@0: // page. We also want thumbnails for those. michael@0: if (aFinalURL != aOriginalURL) { michael@0: yield PageThumbsStorage.copy(aFinalURL, aOriginalURL, aNoOverwrite); michael@0: Services.obs.notifyObservers(null, "page-thumbnail:create", aOriginalURL); michael@0: } michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Register an expiration filter. michael@0: * michael@0: * When thumbnails are going to expire, each registered filter is asked for a michael@0: * list of thumbnails to keep. michael@0: * michael@0: * The filter (if it is a callable) or its filterForThumbnailExpiration method michael@0: * (if the filter is an object) is called with a single argument. The michael@0: * argument is a callback function. The filter must call the callback michael@0: * function and pass it an array of zero or more URLs. (It may do so michael@0: * asynchronously.) Thumbnails for those URLs will be except from expiration. michael@0: * michael@0: * @param aFilter callable, or object with filterForThumbnailExpiration method michael@0: */ michael@0: addExpirationFilter: function PageThumbs_addExpirationFilter(aFilter) { michael@0: PageThumbsExpiration.addFilter(aFilter); michael@0: }, michael@0: michael@0: /** michael@0: * Unregister an expiration filter. michael@0: * @param aFilter A filter that was previously passed to addExpirationFilter. michael@0: */ michael@0: removeExpirationFilter: function PageThumbs_removeExpirationFilter(aFilter) { michael@0: PageThumbsExpiration.removeFilter(aFilter); michael@0: }, michael@0: michael@0: /** michael@0: * Determines the crop size for a given content window. michael@0: * @param aWindow The content window. michael@0: * @param aCanvas The target canvas. michael@0: * @return An array containing width, height and scale. michael@0: */ michael@0: _determineCropSize: function PageThumbs_determineCropSize(aWindow, aCanvas) { michael@0: let utils = aWindow.QueryInterface(Ci.nsIInterfaceRequestor) michael@0: .getInterface(Ci.nsIDOMWindowUtils); michael@0: let sbWidth = {}, sbHeight = {}; michael@0: michael@0: try { michael@0: utils.getScrollbarSize(false, sbWidth, sbHeight); michael@0: } catch (e) { michael@0: // This might fail if the window does not have a presShell. michael@0: Cu.reportError("Unable to get scrollbar size in _determineCropSize."); michael@0: sbWidth.value = sbHeight.value = 0; michael@0: } michael@0: michael@0: // Even in RTL mode, scrollbars are always on the right. michael@0: // So there's no need to determine a left offset. michael@0: let sw = aWindow.innerWidth - sbWidth.value; michael@0: let sh = aWindow.innerHeight - sbHeight.value; michael@0: michael@0: let {width: thumbnailWidth, height: thumbnailHeight} = aCanvas; michael@0: let scale = Math.min(Math.max(thumbnailWidth / sw, thumbnailHeight / sh), 1); michael@0: let scaledWidth = sw * scale; michael@0: let scaledHeight = sh * scale; michael@0: michael@0: if (scaledHeight > thumbnailHeight) michael@0: sh -= Math.floor(Math.abs(scaledHeight - thumbnailHeight) * scale); michael@0: michael@0: if (scaledWidth > thumbnailWidth) michael@0: sw -= Math.floor(Math.abs(scaledWidth - thumbnailWidth) * scale); michael@0: michael@0: return [sw, sh, scale]; michael@0: }, michael@0: michael@0: /** michael@0: * Creates a new hidden canvas element. michael@0: * @param aWindow The document of this window will be used to create the michael@0: * canvas. If not given, the hidden window will be used. michael@0: * @return The newly created canvas. michael@0: */ michael@0: _createCanvas: function PageThumbs_createCanvas(aWindow) { michael@0: let doc = (aWindow || Services.appShell.hiddenDOMWindow).document; michael@0: let canvas = doc.createElementNS(HTML_NAMESPACE, "canvas"); michael@0: canvas.mozOpaque = true; michael@0: canvas.mozImageSmoothingEnabled = true; michael@0: let [thumbnailWidth, thumbnailHeight] = this._getThumbnailSize(); michael@0: canvas.width = thumbnailWidth; michael@0: canvas.height = thumbnailHeight; michael@0: return canvas; michael@0: }, michael@0: michael@0: /** michael@0: * Calculates the thumbnail size based on current desktop's dimensions. michael@0: * @return The calculated thumbnail size or a default if unable to calculate. michael@0: */ michael@0: _getThumbnailSize: function PageThumbs_getThumbnailSize() { michael@0: if (!this._thumbnailWidth || !this._thumbnailHeight) { michael@0: let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"] michael@0: .getService(Ci.nsIScreenManager); michael@0: let left = {}, top = {}, width = {}, height = {}; michael@0: screenManager.primaryScreen.GetRectDisplayPix(left, top, width, height); michael@0: this._thumbnailWidth = Math.round(width.value / 3); michael@0: this._thumbnailHeight = Math.round(height.value / 3); michael@0: } michael@0: return [this._thumbnailWidth, this._thumbnailHeight]; michael@0: }, michael@0: michael@0: /** michael@0: * Given a channel, returns true if it should be considered an "error michael@0: * response", false otherwise. michael@0: */ michael@0: _isChannelErrorResponse: function(channel) { michael@0: // No valid document channel sounds like an error to me! michael@0: if (!channel) michael@0: return true; michael@0: if (!(channel instanceof Ci.nsIHttpChannel)) michael@0: // it might be FTP etc, so assume it's ok. michael@0: return false; michael@0: try { michael@0: return !channel.requestSucceeded; michael@0: } catch (_) { michael@0: // not being able to determine success is surely failure! michael@0: return true; michael@0: } michael@0: }, michael@0: michael@0: _prefEnabled: function PageThumbs_prefEnabled() { michael@0: try { michael@0: return !Services.prefs.getBoolPref("browser.pagethumbnails.capturing_disabled"); michael@0: } michael@0: catch (e) { michael@0: return true; michael@0: } michael@0: }, michael@0: }; michael@0: michael@0: this.PageThumbsStorage = { michael@0: // The path for the storage michael@0: _path: null, michael@0: get path() { michael@0: if (!this._path) { michael@0: this._path = OS.Path.join(OS.Constants.Path.localProfileDir, THUMBNAIL_DIRECTORY); michael@0: } michael@0: return this._path; michael@0: }, michael@0: michael@0: ensurePath: function Storage_ensurePath() { michael@0: // Create the directory (ignore any error if the directory michael@0: // already exists). As all writes are done from the PageThumbsWorker michael@0: // thread, which serializes its operations, this ensures that michael@0: // future operations can proceed without having to check whether michael@0: // the directory exists. michael@0: return PageThumbsWorker.post("makeDir", michael@0: [this.path, {ignoreExisting: true}]).then( michael@0: null, michael@0: function onError(aReason) { michael@0: Components.utils.reportError("Could not create thumbnails directory" + aReason); michael@0: }); michael@0: }, michael@0: michael@0: getLeafNameForURL: function Storage_getLeafNameForURL(aURL) { michael@0: if (typeof aURL != "string") { michael@0: throw new TypeError("Expecting a string"); michael@0: } michael@0: let hash = this._calculateMD5Hash(aURL); michael@0: return hash + ".png"; michael@0: }, michael@0: michael@0: getFilePathForURL: function Storage_getFilePathForURL(aURL) { michael@0: return OS.Path.join(this.path, this.getLeafNameForURL(aURL)); michael@0: }, michael@0: michael@0: /** michael@0: * Write the contents of a thumbnail, off the main thread. michael@0: * michael@0: * @param {string} aURL The url for which to store a thumbnail. michael@0: * @param {ArrayBuffer} aData The data to store in the thumbnail, as michael@0: * an ArrayBuffer. This array buffer is neutered and cannot be michael@0: * reused after the copy. michael@0: * @param {boolean} aNoOverwrite If true and the thumbnail's file already michael@0: * exists, the file will not be overwritten. michael@0: * michael@0: * @return {Promise} michael@0: */ michael@0: writeData: function Storage_writeData(aURL, aData, aNoOverwrite) { michael@0: let path = this.getFilePathForURL(aURL); michael@0: this.ensurePath(); michael@0: aData = new Uint8Array(aData); michael@0: let msg = [ michael@0: path, michael@0: aData, michael@0: { michael@0: tmpPath: path + ".tmp", michael@0: bytes: aData.byteLength, michael@0: noOverwrite: aNoOverwrite, michael@0: flush: false /*thumbnails do not require the level of guarantee provided by flush*/ michael@0: }]; michael@0: return PageThumbsWorker.post("writeAtomic", msg, michael@0: msg /*we don't want that message garbage-collected, michael@0: as OS.Shared.Type.void_t.in_ptr.toMsg uses C-level michael@0: memory tricks to enforce zero-copy*/). michael@0: then(null, this._eatNoOverwriteError(aNoOverwrite)); michael@0: }, michael@0: michael@0: /** michael@0: * Copy a thumbnail, off the main thread. michael@0: * michael@0: * @param {string} aSourceURL The url of the thumbnail to copy. michael@0: * @param {string} aTargetURL The url of the target thumbnail. michael@0: * @param {boolean} aNoOverwrite If true and the target file already exists, michael@0: * the file will not be overwritten. michael@0: * michael@0: * @return {Promise} michael@0: */ michael@0: copy: function Storage_copy(aSourceURL, aTargetURL, aNoOverwrite) { michael@0: this.ensurePath(); michael@0: let sourceFile = this.getFilePathForURL(aSourceURL); michael@0: let targetFile = this.getFilePathForURL(aTargetURL); michael@0: let options = { noOverwrite: aNoOverwrite }; michael@0: return PageThumbsWorker.post("copy", [sourceFile, targetFile, options]). michael@0: then(null, this._eatNoOverwriteError(aNoOverwrite)); michael@0: }, michael@0: michael@0: /** michael@0: * Remove a single thumbnail, off the main thread. michael@0: * michael@0: * @return {Promise} michael@0: */ michael@0: remove: function Storage_remove(aURL) { michael@0: return PageThumbsWorker.post("remove", [this.getFilePathForURL(aURL)]); michael@0: }, michael@0: michael@0: /** michael@0: * Remove all thumbnails, off the main thread. michael@0: * michael@0: * @return {Promise} michael@0: */ michael@0: wipe: Task.async(function* Storage_wipe() { michael@0: // michael@0: // This operation may be launched during shutdown, so we need to michael@0: // take a few precautions to ensure that: michael@0: // michael@0: // 1. it is not interrupted by shutdown, in which case we michael@0: // could be leaving privacy-sensitive files on disk; michael@0: // 2. it is not launched too late during shutdown, in which michael@0: // case this could cause shutdown freezes (see bug 1005487, michael@0: // which will eventually be fixed by bug 965309) michael@0: // michael@0: michael@0: let blocker = () => promise; michael@0: michael@0: // The following operation will rise an error if we have already michael@0: // reached profileBeforeChange, in which case it is too late michael@0: // to clear the thumbnail wipe. michael@0: AsyncShutdown.profileBeforeChange.addBlocker( michael@0: "PageThumbs: removing all thumbnails", michael@0: blocker); michael@0: michael@0: // Start the work only now that `profileBeforeChange` has had michael@0: // a chance to throw an error. michael@0: michael@0: let promise = PageThumbsWorker.post("wipe", [this.path]); michael@0: try { michael@0: yield promise; michael@0: } finally { michael@0: // Generally, we will be done much before profileBeforeChange, michael@0: // so let's not hoard blockers. michael@0: if ("removeBlocker" in AsyncShutdown.profileBeforeChange) { michael@0: // `removeBlocker` was added with bug 985655. In the interest michael@0: // of backporting, let's degrade gracefully if `removeBlocker` michael@0: // doesn't exist. michael@0: AsyncShutdown.profileBeforeChange.removeBlocker(blocker); michael@0: } michael@0: } michael@0: }), michael@0: michael@0: fileExistsForURL: function Storage_fileExistsForURL(aURL) { michael@0: return PageThumbsWorker.post("exists", [this.getFilePathForURL(aURL)]); michael@0: }, michael@0: michael@0: isFileRecentForURL: function Storage_isFileRecentForURL(aURL) { michael@0: return PageThumbsWorker.post("isFileRecent", michael@0: [this.getFilePathForURL(aURL), michael@0: MAX_THUMBNAIL_AGE_SECS]); michael@0: }, michael@0: michael@0: _calculateMD5Hash: function Storage_calculateMD5Hash(aValue) { michael@0: let hash = gCryptoHash; michael@0: let value = gUnicodeConverter.convertToByteArray(aValue); michael@0: michael@0: hash.init(hash.MD5); michael@0: hash.update(value, value.length); michael@0: return this._convertToHexString(hash.finish(false)); michael@0: }, michael@0: michael@0: _convertToHexString: function Storage_convertToHexString(aData) { michael@0: let hex = ""; michael@0: for (let i = 0; i < aData.length; i++) michael@0: hex += ("0" + aData.charCodeAt(i).toString(16)).slice(-2); michael@0: return hex; michael@0: }, michael@0: michael@0: /** michael@0: * For functions that take a noOverwrite option, OS.File throws an error if michael@0: * the target file exists and noOverwrite is true. We don't consider that an michael@0: * error, and we don't want such errors propagated. michael@0: * michael@0: * @param {aNoOverwrite} The noOverwrite option used in the OS.File operation. michael@0: * michael@0: * @return {function} A function that should be passed as the second argument michael@0: * to then() (the `onError` argument). michael@0: */ michael@0: _eatNoOverwriteError: function Storage__eatNoOverwriteError(aNoOverwrite) { michael@0: return function onError(err) { michael@0: if (!aNoOverwrite || michael@0: !(err instanceof OS.File.Error) || michael@0: !err.becauseExists) { michael@0: throw err; michael@0: } michael@0: }; michael@0: }, michael@0: michael@0: // Deprecated, please do not use michael@0: getFileForURL: function Storage_getFileForURL_DEPRECATED(aURL) { michael@0: Deprecated.warning("PageThumbs.getFileForURL is deprecated. Please use PageThumbs.getFilePathForURL and OS.File", michael@0: "https://developer.mozilla.org/docs/JavaScript_OS.File"); michael@0: // Note: Once this method has been removed, we can get rid of the dependency towards FileUtils michael@0: return new FileUtils.File(PageThumbsStorage.getFilePathForURL(aURL)); michael@0: } michael@0: }; michael@0: michael@0: let PageThumbsStorageMigrator = { michael@0: get currentVersion() { michael@0: try { michael@0: return Services.prefs.getIntPref(PREF_STORAGE_VERSION); michael@0: } catch (e) { michael@0: // The pref doesn't exist, yet. Return version 0. michael@0: return 0; michael@0: } michael@0: }, michael@0: michael@0: set currentVersion(aVersion) { michael@0: Services.prefs.setIntPref(PREF_STORAGE_VERSION, aVersion); michael@0: }, michael@0: michael@0: migrate: function Migrator_migrate() { michael@0: let version = this.currentVersion; michael@0: michael@0: // Storage version 1 never made it to beta. michael@0: // At the time of writing only Windows had (ProfD != ProfLD) and we michael@0: // needed to move thumbnails from the roaming profile to the locale michael@0: // one so that they're not needlessly included in backups and/or michael@0: // written via SMB. michael@0: michael@0: // Storage version 2 also never made it to beta. michael@0: // The thumbnail folder structure has been changed and old thumbnails michael@0: // were not migrated. Instead, we just renamed the current folder to michael@0: // "-old" and will remove it later. michael@0: michael@0: if (version < 3) { michael@0: this.migrateToVersion3(); michael@0: } michael@0: michael@0: this.currentVersion = LATEST_STORAGE_VERSION; michael@0: }, michael@0: michael@0: /** michael@0: * Bug 239254 added support for having the disk cache and thumbnail michael@0: * directories on a local path (i.e. ~/.cache/) under Linux. We'll first michael@0: * try to move the old thumbnails to their new location. If that's not michael@0: * possible (because ProfD might be on a different file system than michael@0: * ProfLD) we'll just discard them. michael@0: * michael@0: * @param {string*} local The path to the local profile directory. michael@0: * Used for testing. Default argument is good for all non-testing uses. michael@0: * @param {string*} roaming The path to the roaming profile directory. michael@0: * Used for testing. Default argument is good for all non-testing uses. michael@0: */ michael@0: migrateToVersion3: function Migrator_migrateToVersion3( michael@0: local = OS.Constants.Path.localProfileDir, michael@0: roaming = OS.Constants.Path.profileDir) { michael@0: PageThumbsWorker.post( michael@0: "moveOrDeleteAllThumbnails", michael@0: [OS.Path.join(roaming, THUMBNAIL_DIRECTORY), michael@0: OS.Path.join(local, THUMBNAIL_DIRECTORY)] michael@0: ); michael@0: } michael@0: }; michael@0: michael@0: let PageThumbsExpiration = { michael@0: _filters: [], michael@0: michael@0: init: function Expiration_init() { michael@0: gUpdateTimerManager.registerTimer("browser-cleanup-thumbnails", this, michael@0: EXPIRATION_INTERVAL_SECS); michael@0: }, michael@0: michael@0: addFilter: function Expiration_addFilter(aFilter) { michael@0: this._filters.push(aFilter); michael@0: }, michael@0: michael@0: removeFilter: function Expiration_removeFilter(aFilter) { michael@0: let index = this._filters.indexOf(aFilter); michael@0: if (index > -1) michael@0: this._filters.splice(index, 1); michael@0: }, michael@0: michael@0: notify: function Expiration_notify(aTimer) { michael@0: let urls = []; michael@0: let filtersToWaitFor = this._filters.length; michael@0: michael@0: let expire = function expire() { michael@0: this.expireThumbnails(urls); michael@0: }.bind(this); michael@0: michael@0: // No registered filters. michael@0: if (!filtersToWaitFor) { michael@0: expire(); michael@0: return; michael@0: } michael@0: michael@0: function filterCallback(aURLs) { michael@0: urls = urls.concat(aURLs); michael@0: if (--filtersToWaitFor == 0) michael@0: expire(); michael@0: } michael@0: michael@0: for (let filter of this._filters) { michael@0: if (typeof filter == "function") michael@0: filter(filterCallback) michael@0: else michael@0: filter.filterForThumbnailExpiration(filterCallback); michael@0: } michael@0: }, michael@0: michael@0: expireThumbnails: function Expiration_expireThumbnails(aURLsToKeep) { michael@0: let path = this.path; michael@0: let keep = [PageThumbsStorage.getLeafNameForURL(url) for (url of aURLsToKeep)]; michael@0: let msg = [ michael@0: PageThumbsStorage.path, michael@0: keep, michael@0: EXPIRATION_MIN_CHUNK_SIZE michael@0: ]; michael@0: michael@0: return PageThumbsWorker.post( michael@0: "expireFilesInDirectory", michael@0: msg michael@0: ); michael@0: } michael@0: }; michael@0: michael@0: /** michael@0: * Interface to a dedicated thread handling I/O michael@0: */ michael@0: michael@0: let PageThumbsWorker = (function() { michael@0: let worker = new PromiseWorker("resource://gre/modules/PageThumbsWorker.js", michael@0: OS.Shared.LOG.bind("PageThumbs")); michael@0: return { michael@0: post: function post(...args) { michael@0: let promise = worker.post.apply(worker, args); michael@0: return promise.then( michael@0: null, michael@0: function onError(error) { michael@0: // Decode any serialized error michael@0: if (error instanceof PromiseWorker.WorkerError) { michael@0: throw OS.File.Error.fromMsg(error.data); michael@0: } else { michael@0: throw error; michael@0: } michael@0: } michael@0: ); michael@0: } michael@0: }; michael@0: })(); michael@0: michael@0: let PageThumbsHistoryObserver = { michael@0: onDeleteURI: function Thumbnails_onDeleteURI(aURI, aGUID) { michael@0: PageThumbsStorage.remove(aURI.spec); michael@0: }, michael@0: michael@0: onClearHistory: function Thumbnails_onClearHistory() { michael@0: PageThumbsStorage.wipe(); michael@0: }, michael@0: michael@0: onTitleChanged: function () {}, michael@0: onBeginUpdateBatch: function () {}, michael@0: onEndUpdateBatch: function () {}, michael@0: onVisit: function () {}, michael@0: onPageChanged: function () {}, michael@0: onDeleteVisits: function () {}, michael@0: michael@0: QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver]) michael@0: };