summaryrefslogtreecommitdiff
path: root/components/thumbnails/PageThumbs.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'components/thumbnails/PageThumbs.jsm')
-rw-r--r--components/thumbnails/PageThumbs.jsm902
1 files changed, 902 insertions, 0 deletions
diff --git a/components/thumbnails/PageThumbs.jsm b/components/thumbnails/PageThumbs.jsm
new file mode 100644
index 000000000..714bbbb77
--- /dev/null
+++ b/components/thumbnails/PageThumbs.jsm
@@ -0,0 +1,902 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["PageThumbs", "PageThumbsStorage"];
+
+const Cu = Components.utils;
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+
+const PREF_STORAGE_VERSION = "browser.pagethumbnails.storage_version";
+const LATEST_STORAGE_VERSION = 3;
+
+const EXPIRATION_MIN_CHUNK_SIZE = 50;
+const EXPIRATION_INTERVAL_SECS = 3600;
+
+var gRemoteThumbId = 0;
+
+// If a request for a thumbnail comes in and we find one that is "stale"
+// (or don't find one at all) we automatically queue a request to generate a
+// new one.
+const MAX_THUMBNAIL_AGE_SECS = 172800; // 2 days == 60*60*24*2 == 172800 secs.
+
+/**
+ * Name of the directory in the profile that contains the thumbnails.
+ */
+const THUMBNAIL_DIRECTORY = "thumbnails";
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
+Cu.import("resource://gre/modules/PromiseWorker.jsm", this);
+Cu.import("resource://gre/modules/Promise.jsm", this);
+Cu.import("resource://gre/modules/osfile.jsm", this);
+
+Cu.importGlobalProperties(['FileReader']);
+
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+ "resource://gre/modules/FileUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "gUpdateTimerManager",
+ "@mozilla.org/updates/timer-manager;1", "nsIUpdateTimerManager");
+
+XPCOMUtils.defineLazyGetter(this, "gCryptoHash", function () {
+ return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
+});
+
+XPCOMUtils.defineLazyGetter(this, "gUnicodeConverter", function () {
+ let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
+ .createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = 'utf8';
+ return converter;
+});
+
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
+ "resource://gre/modules/AsyncShutdown.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PageThumbUtils",
+ "resource://gre/modules/PageThumbUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+
+/**
+ * Utilities for dealing with promises and Task.jsm
+ */
+const TaskUtils = {
+ /**
+ * Read the bytes from a blob, asynchronously.
+ *
+ * @return {Promise}
+ * @resolve {ArrayBuffer} In case of success, the bytes contained in the blob.
+ * @reject {DOMError} In case of error, the underlying DOMError.
+ */
+ readBlob: function readBlob(blob) {
+ let deferred = Promise.defer();
+ let reader = new FileReader();
+ reader.onloadend = function onloadend() {
+ if (reader.readyState != FileReader.DONE) {
+ deferred.reject(reader.error);
+ } else {
+ deferred.resolve(reader.result);
+ }
+ };
+ reader.readAsArrayBuffer(blob);
+ return deferred.promise;
+ }
+};
+
+
+
+
+/**
+ * Singleton providing functionality for capturing web page thumbnails and for
+ * accessing them if already cached.
+ */
+this.PageThumbs = {
+ _initialized: false,
+
+ /**
+ * The calculated width and height of the thumbnails.
+ */
+ _thumbnailWidth : 0,
+ _thumbnailHeight : 0,
+
+ /**
+ * The scheme to use for thumbnail urls.
+ */
+ get scheme() {
+ return "moz-page-thumb";
+ },
+
+ /**
+ * The static host to use for thumbnail urls.
+ */
+ get staticHost() {
+ return "thumbnail";
+ },
+
+ /**
+ * The thumbnails' image type.
+ */
+ get contentType() {
+ return "image/png";
+ },
+
+ init: function PageThumbs_init() {
+ if (!this._initialized) {
+ this._initialized = true;
+ PlacesUtils.history.addObserver(PageThumbsHistoryObserver, true);
+
+ // Migrate the underlying storage, if needed.
+ PageThumbsStorageMigrator.migrate();
+ PageThumbsExpiration.init();
+ }
+ },
+
+ uninit: function PageThumbs_uninit() {
+ if (this._initialized) {
+ this._initialized = false;
+ }
+ },
+
+ /**
+ * Gets the thumbnail image's url for a given web page's url.
+ * @param aUrl The web page's url that is depicted in the thumbnail.
+ * @return The thumbnail image's url.
+ */
+ getThumbnailURL: function PageThumbs_getThumbnailURL(aUrl) {
+ return this.scheme + "://" + this.staticHost +
+ "/?url=" + encodeURIComponent(aUrl) +
+ "&revision=" + PageThumbsStorage.getRevision(aUrl);
+ },
+
+ /**
+ * Gets the path of the thumbnail file for a given web page's
+ * url. This file may or may not exist depending on whether the
+ * thumbnail has been captured or not.
+ *
+ * @param aUrl The web page's url.
+ * @return The path of the thumbnail file.
+ */
+ getThumbnailPath: function PageThumbs_getThumbnailPath(aUrl) {
+ return PageThumbsStorage.getFilePathForURL(aUrl);
+ },
+
+ /**
+ * Asynchronously returns a thumbnail as a blob for the given
+ * window.
+ *
+ * @param aBrowser The <browser> to capture a thumbnail from.
+ * @return {Promise}
+ * @resolve {Blob} The thumbnail, as a Blob.
+ */
+ captureToBlob: function PageThumbs_captureToBlob(aBrowser) {
+ if (!this._prefEnabled()) {
+ return null;
+ }
+
+ let deferred = Promise.defer();
+
+ let canvas = this.createCanvas(aBrowser.contentWindow);
+ this.captureToCanvas(aBrowser, canvas, () => {
+ canvas.toBlob(blob => {
+ deferred.resolve(blob, this.contentType);
+ });
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Captures a thumbnail from a given window and draws it to the given canvas.
+ * Note, when dealing with remote content, this api draws into the passed
+ * canvas asynchronously. Pass aCallback to receive an async callback after
+ * canvas painting has completed.
+ * @param aBrowser The browser to capture a thumbnail from.
+ * @param aCanvas The canvas to draw to. The thumbnail will be scaled to match
+ * the dimensions of this canvas. If callers pass a 0x0 canvas, the canvas
+ * will be resized to default thumbnail dimensions just prior to painting.
+ * @param aCallback (optional) A callback invoked once the thumbnail has been
+ * rendered to aCanvas.
+ * @param aArgs (optional) Additional named parameters:
+ * fullScale - request that a non-downscaled image be returned.
+ */
+ captureToCanvas: function (aBrowser, aCanvas, aCallback, aArgs) {
+ let args = {
+ fullScale: aArgs ? aArgs.fullScale : false
+ };
+ this._captureToCanvas(aBrowser, aCanvas, args, (aCanvas) => {
+ if (aCallback) {
+ aCallback(aCanvas);
+ }
+ });
+ },
+
+ /**
+ * Asynchronously check the state of aBrowser to see if it passes a set of
+ * predefined security checks. Consumers should refrain from storing
+ * thumbnails if these checks fail. Note the final result of this call is
+ * transitory as it is based on current navigation state and the type of
+ * content being displayed.
+ *
+ * @param aBrowser The target browser
+ * @param aCallback(aResult) A callback invoked once security checks have
+ * completed. aResult is a boolean indicating the combined result of the
+ * security checks performed.
+ */
+ shouldStoreThumbnail: function (aBrowser, aCallback) {
+ // Don't capture in private browsing mode.
+ if (PrivateBrowsingUtils.isBrowserPrivate(aBrowser)) {
+ aCallback(false);
+ return;
+ }
+ if (aBrowser.isRemoteBrowser) {
+ let mm = aBrowser.messageManager;
+ let resultFunc = function (aMsg) {
+ mm.removeMessageListener("Browser:Thumbnail:CheckState:Response", resultFunc);
+ aCallback(aMsg.data.result);
+ }
+ mm.addMessageListener("Browser:Thumbnail:CheckState:Response", resultFunc);
+ try {
+ mm.sendAsyncMessage("Browser:Thumbnail:CheckState");
+ } catch (ex) {
+ Cu.reportError(ex);
+ // If the message manager is not able send our message, taking a content
+ // screenshot is also not going to work: return false.
+ resultFunc({ data: { result: false } });
+ }
+ } else {
+ aCallback(PageThumbUtils.shouldStoreContentThumbnail(aBrowser.contentDocument,
+ aBrowser.docShell));
+ }
+ },
+
+ // The background thumbnail service captures to canvas but doesn't want to
+ // participate in this service's telemetry, which is why this method exists.
+ _captureToCanvas: function (aBrowser, aCanvas, aArgs, aCallback) {
+ if (aBrowser.isRemoteBrowser) {
+ Task.spawn(function* () {
+ let data =
+ yield this._captureRemoteThumbnail(aBrowser, aCanvas.width,
+ aCanvas.height, aArgs);
+ let canvas = data.thumbnail;
+ let ctx = canvas.getContext("2d");
+ let imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ aCanvas.width = canvas.width;
+ aCanvas.height = canvas.height;
+ aCanvas.getContext("2d").putImageData(imgData, 0, 0);
+ if (aCallback) {
+ aCallback(aCanvas);
+ }
+ }.bind(this));
+ return;
+ }
+ // The content is a local page, grab a thumbnail sync.
+ PageThumbUtils.createSnapshotThumbnail(aBrowser.contentWindow,
+ aCanvas,
+ aArgs);
+
+ if (aCallback) {
+ aCallback(aCanvas);
+ }
+ },
+
+ /**
+ * Asynchrnously render an appropriately scaled thumbnail to canvas.
+ *
+ * @param aBrowser The browser to capture a thumbnail from.
+ * @param aWidth The desired canvas width.
+ * @param aHeight The desired canvas height.
+ * @param aArgs (optional) Additional named parameters:
+ * fullScale - request that a non-downscaled image be returned.
+ * @return a promise
+ */
+ _captureRemoteThumbnail: function (aBrowser, aWidth, aHeight, aArgs) {
+ let deferred = Promise.defer();
+
+ // The index we send with the request so we can identify the
+ // correct response.
+ let index = gRemoteThumbId++;
+
+ // Thumbnail request response handler
+ let mm = aBrowser.messageManager;
+
+ // Browser:Thumbnail:Response handler
+ let thumbFunc = function (aMsg) {
+ // Ignore events unrelated to our request
+ if (aMsg.data.id != index) {
+ return;
+ }
+
+ mm.removeMessageListener("Browser:Thumbnail:Response", thumbFunc);
+ let imageBlob = aMsg.data.thumbnail;
+ let doc = aBrowser.parentElement.ownerDocument;
+ let reader = new FileReader();
+ reader.addEventListener("loadend", function() {
+ let image = doc.createElementNS(PageThumbUtils.HTML_NAMESPACE, "img");
+ image.onload = function () {
+ let thumbnail = doc.createElementNS(PageThumbUtils.HTML_NAMESPACE, "canvas");
+ thumbnail.width = image.naturalWidth;
+ thumbnail.height = image.naturalHeight;
+ let ctx = thumbnail.getContext("2d");
+ ctx.drawImage(image, 0, 0);
+ deferred.resolve({
+ thumbnail: thumbnail
+ });
+ }
+ image.src = reader.result;
+ });
+ // xxx wish there was a way to skip this encoding step
+ reader.readAsDataURL(imageBlob);
+ }
+
+ // Send a thumbnail request
+ mm.addMessageListener("Browser:Thumbnail:Response", thumbFunc);
+ mm.sendAsyncMessage("Browser:Thumbnail:Request", {
+ canvasWidth: aWidth,
+ canvasHeight: aHeight,
+ background: PageThumbUtils.THUMBNAIL_BG_COLOR,
+ id: index,
+ additionalArgs: aArgs
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Captures a thumbnail for the given browser and stores it to the cache.
+ * @param aBrowser The browser to capture a thumbnail for.
+ * @param aCallback The function to be called when finished (optional).
+ */
+ captureAndStore: function PageThumbs_captureAndStore(aBrowser, aCallback) {
+ if (!this._prefEnabled()) {
+ return;
+ }
+
+ let url = aBrowser.currentURI.spec;
+ let originalURL;
+ let channelError = false;
+
+ Task.spawn((function* task() {
+ if (!aBrowser.isRemoteBrowser) {
+ let channel = aBrowser.docShell.currentDocumentChannel;
+ originalURL = channel.originalURI.spec;
+ // see if this was an error response.
+ channelError = PageThumbUtils.isChannelErrorResponse(channel);
+ } else {
+ let resp = yield new Promise(resolve => {
+ let mm = aBrowser.messageManager;
+ let respName = "Browser:Thumbnail:GetOriginalURL:Response";
+ mm.addMessageListener(respName, function onResp(msg) {
+ mm.removeMessageListener(respName, onResp);
+ resolve(msg.data);
+ });
+ mm.sendAsyncMessage("Browser:Thumbnail:GetOriginalURL");
+ });
+ originalURL = resp.originalURL || url;
+ channelError = resp.channelError;
+ }
+
+ let isSuccess = true;
+ try {
+ let blob = yield this.captureToBlob(aBrowser);
+ let buffer = yield TaskUtils.readBlob(blob);
+ yield this._store(originalURL, url, buffer, channelError);
+ } catch (ex) {
+ Components.utils.reportError("Exception thrown during thumbnail capture: '" + ex + "'");
+ isSuccess = false;
+ }
+ if (aCallback) {
+ aCallback(isSuccess);
+ }
+ }).bind(this));
+ },
+
+ /**
+ * Checks if an existing thumbnail for the specified URL is either missing
+ * or stale, and if so, captures and stores it. Once the thumbnail is stored,
+ * an observer service notification will be sent, so consumers should observe
+ * such notifications if they want to be notified of an updated thumbnail.
+ *
+ * @param aBrowser The content window of this browser will be captured.
+ * @param aCallback The function to be called when finished (optional).
+ */
+ captureAndStoreIfStale: function PageThumbs_captureAndStoreIfStale(aBrowser, aCallback) {
+ let url = aBrowser.currentURI.spec;
+ PageThumbsStorage.isFileRecentForURL(url).then(recent => {
+ if (!recent &&
+ // Careful, the call to PageThumbsStorage is async, so the browser may
+ // have navigated away from the URL or even closed.
+ aBrowser.currentURI &&
+ aBrowser.currentURI.spec == url) {
+ this.captureAndStore(aBrowser, aCallback);
+ } else if (aCallback) {
+ aCallback(true);
+ }
+ }, err => {
+ if (aCallback)
+ aCallback(false);
+ });
+ },
+
+ /**
+ * Stores data to disk for the given URLs.
+ *
+ * NB: The background thumbnail service calls this, too.
+ *
+ * @param aOriginalURL The URL with which the capture was initiated.
+ * @param aFinalURL The URL to which aOriginalURL ultimately resolved.
+ * @param aData An ArrayBuffer containing the image data.
+ * @param aNoOverwrite If true and files for the URLs already exist, the files
+ * will not be overwritten.
+ * @return {Promise}
+ */
+ _store: function PageThumbs__store(aOriginalURL, aFinalURL, aData, aNoOverwrite) {
+ return Task.spawn(function* () {
+ yield PageThumbsStorage.writeData(aFinalURL, aData, aNoOverwrite);
+
+ Services.obs.notifyObservers(null, "page-thumbnail:create", aFinalURL);
+ // We've been redirected. Create a copy of the current thumbnail for
+ // the redirect source. We need to do this because:
+ //
+ // 1) Users can drag any kind of links onto the newtab page. If those
+ // links redirect to a different URL then we want to be able to
+ // provide thumbnails for both of them.
+ //
+ // 2) The newtab page should actually display redirect targets, only.
+ // Because of bug 559175 this information can get lost when using
+ // Sync and therefore also redirect sources appear on the newtab
+ // page. We also want thumbnails for those.
+ if (aFinalURL != aOriginalURL) {
+ yield PageThumbsStorage.copy(aFinalURL, aOriginalURL, aNoOverwrite);
+ Services.obs.notifyObservers(null, "page-thumbnail:create", aOriginalURL);
+ }
+ });
+ },
+
+ /**
+ * Register an expiration filter.
+ *
+ * When thumbnails are going to expire, each registered filter is asked for a
+ * list of thumbnails to keep.
+ *
+ * The filter (if it is a callable) or its filterForThumbnailExpiration method
+ * (if the filter is an object) is called with a single argument. The
+ * argument is a callback function. The filter must call the callback
+ * function and pass it an array of zero or more URLs. (It may do so
+ * asynchronously.) Thumbnails for those URLs will be except from expiration.
+ *
+ * @param aFilter callable, or object with filterForThumbnailExpiration method
+ */
+ addExpirationFilter: function PageThumbs_addExpirationFilter(aFilter) {
+ PageThumbsExpiration.addFilter(aFilter);
+ },
+
+ /**
+ * Unregister an expiration filter.
+ * @param aFilter A filter that was previously passed to addExpirationFilter.
+ */
+ removeExpirationFilter: function PageThumbs_removeExpirationFilter(aFilter) {
+ PageThumbsExpiration.removeFilter(aFilter);
+ },
+
+ /**
+ * Creates a new hidden canvas element.
+ * @param aWindow The document of this window will be used to create the
+ * canvas. If not given, the hidden window will be used.
+ * @return The newly created canvas.
+ */
+ createCanvas: function PageThumbs_createCanvas(aWindow) {
+ return PageThumbUtils.createCanvas(aWindow);
+ },
+
+ _prefEnabled: function PageThumbs_prefEnabled() {
+ try {
+ return !Services.prefs.getBoolPref("browser.pagethumbnails.capturing_disabled");
+ }
+ catch (e) {
+ return true;
+ }
+ },
+};
+
+this.PageThumbsStorage = {
+ // The path for the storage
+ _path: null,
+ get path() {
+ if (!this._path) {
+ this._path = OS.Path.join(OS.Constants.Path.localProfileDir, THUMBNAIL_DIRECTORY);
+ }
+ return this._path;
+ },
+
+ ensurePath: function Storage_ensurePath() {
+ // Create the directory (ignore any error if the directory
+ // already exists). As all writes are done from the PageThumbsWorker
+ // thread, which serializes its operations, this ensures that
+ // future operations can proceed without having to check whether
+ // the directory exists.
+ return PageThumbsWorker.post("makeDir",
+ [this.path, {ignoreExisting: true}]).then(
+ null,
+ function onError(aReason) {
+ Components.utils.reportError("Could not create thumbnails directory" + aReason);
+ });
+ },
+
+ getLeafNameForURL: function Storage_getLeafNameForURL(aURL) {
+ if (typeof aURL != "string") {
+ throw new TypeError("Expecting a string");
+ }
+ let hash = this._calculateMD5Hash(aURL);
+ return hash + ".png";
+ },
+
+ getFilePathForURL: function Storage_getFilePathForURL(aURL) {
+ return OS.Path.join(this.path, this.getLeafNameForURL(aURL));
+ },
+
+ _revisionTable: {},
+
+ // Generate an arbitrary revision tag, i.e. one that can't be used to
+ // infer URL frecency.
+ _updateRevision(aURL) {
+ // Initialize with a random value and increment on each update. Wrap around
+ // modulo _revisionRange, so that even small values carry no meaning.
+ let rev = this._revisionTable[aURL];
+ if (rev == null)
+ rev = Math.floor(Math.random() * this._revisionRange);
+ this._revisionTable[aURL] = (rev + 1) % this._revisionRange;
+ },
+
+ // If two thumbnails with the same URL and revision are in cache at the
+ // same time, the image loader may pick the stale thumbnail in some cases.
+ // Therefore _revisionRange must be large enough to prevent this, e.g.
+ // in the pathological case image.cache.size (5MB by default) could fill
+ // with (abnormally small) 10KB thumbnail images if the browser session
+ // runs long enough (though this is unlikely as thumbnails are usually
+ // only updated every MAX_THUMBNAIL_AGE_SECS).
+ _revisionRange: 8192,
+
+ /**
+ * Return a revision tag for the thumbnail stored for a given URL.
+ *
+ * @param aURL The URL spec string
+ * @return A revision tag for the corresponding thumbnail. Returns a changed
+ * value whenever the stored thumbnail changes.
+ */
+ getRevision(aURL) {
+ let rev = this._revisionTable[aURL];
+ if (rev == null) {
+ this._updateRevision(aURL);
+ rev = this._revisionTable[aURL];
+ }
+ return rev;
+ },
+
+ /**
+ * Write the contents of a thumbnail, off the main thread.
+ *
+ * @param {string} aURL The url for which to store a thumbnail.
+ * @param {ArrayBuffer} aData The data to store in the thumbnail, as
+ * an ArrayBuffer. This array buffer will be detached and cannot be
+ * reused after the copy.
+ * @param {boolean} aNoOverwrite If true and the thumbnail's file already
+ * exists, the file will not be overwritten.
+ *
+ * @return {Promise}
+ */
+ writeData: function Storage_writeData(aURL, aData, aNoOverwrite) {
+ let path = this.getFilePathForURL(aURL);
+ this.ensurePath();
+
+ // XXX: We try/catch here since 'null' isn't accepted until we implement
+ // ES2017's new Uint8Array(); allowance.
+ try {
+ aData = new Uint8Array(aData);
+ } catch(e) {
+ aData = new Uint8Array(0);
+ }
+
+ let msg = [
+ path,
+ aData,
+ {
+ tmpPath: path + ".tmp",
+ bytes: aData.byteLength,
+ noOverwrite: aNoOverwrite,
+ flush: false /* thumbnails do not require the level of guarantee provided by flush*/
+ }];
+ return PageThumbsWorker.post("writeAtomic", msg,
+ msg /* we don't want that message garbage-collected,
+ as OS.Shared.Type.void_t.in_ptr.toMsg uses C-level
+ memory tricks to enforce zero-copy*/).
+ then(() => this._updateRevision(aURL), this._eatNoOverwriteError(aNoOverwrite));
+ },
+
+ /**
+ * Copy a thumbnail, off the main thread.
+ *
+ * @param {string} aSourceURL The url of the thumbnail to copy.
+ * @param {string} aTargetURL The url of the target thumbnail.
+ * @param {boolean} aNoOverwrite If true and the target file already exists,
+ * the file will not be overwritten.
+ *
+ * @return {Promise}
+ */
+ copy: function Storage_copy(aSourceURL, aTargetURL, aNoOverwrite) {
+ this.ensurePath();
+ let sourceFile = this.getFilePathForURL(aSourceURL);
+ let targetFile = this.getFilePathForURL(aTargetURL);
+ let options = { noOverwrite: aNoOverwrite };
+ return PageThumbsWorker.post("copy", [sourceFile, targetFile, options]).
+ then(() => this._updateRevision(aTargetURL), this._eatNoOverwriteError(aNoOverwrite));
+ },
+
+ /**
+ * Remove a single thumbnail, off the main thread.
+ *
+ * @return {Promise}
+ */
+ remove: function Storage_remove(aURL) {
+ return PageThumbsWorker.post("remove", [this.getFilePathForURL(aURL)]);
+ },
+
+ /**
+ * Remove all thumbnails, off the main thread.
+ *
+ * @return {Promise}
+ */
+ wipe: Task.async(function* Storage_wipe() {
+ //
+ // This operation may be launched during shutdown, so we need to
+ // take a few precautions to ensure that:
+ //
+ // 1. it is not interrupted by shutdown, in which case we
+ // could be leaving privacy-sensitive files on disk;
+ // 2. it is not launched too late during shutdown, in which
+ // case this could cause shutdown freezes (see bug 1005487,
+ // which will eventually be fixed by bug 965309)
+ //
+
+ let blocker = () => promise;
+
+ // The following operation will rise an error if we have already
+ // reached profileBeforeChange, in which case it is too late
+ // to clear the thumbnail wipe.
+ AsyncShutdown.profileBeforeChange.addBlocker(
+ "PageThumbs: removing all thumbnails",
+ blocker);
+
+ // Start the work only now that `profileBeforeChange` has had
+ // a chance to throw an error.
+
+ let promise = PageThumbsWorker.post("wipe", [this.path]);
+ try {
+ yield promise;
+ } finally {
+ // Generally, we will be done much before profileBeforeChange,
+ // so let's not hoard blockers.
+ if ("removeBlocker" in AsyncShutdown.profileBeforeChange) {
+ // `removeBlocker` was added with bug 985655. In the interest
+ // of backporting, let's degrade gracefully if `removeBlocker`
+ // doesn't exist.
+ AsyncShutdown.profileBeforeChange.removeBlocker(blocker);
+ }
+ }
+ }),
+
+ fileExistsForURL: function Storage_fileExistsForURL(aURL) {
+ return PageThumbsWorker.post("exists", [this.getFilePathForURL(aURL)]);
+ },
+
+ isFileRecentForURL: function Storage_isFileRecentForURL(aURL) {
+ return PageThumbsWorker.post("isFileRecent",
+ [this.getFilePathForURL(aURL),
+ MAX_THUMBNAIL_AGE_SECS]);
+ },
+
+ _calculateMD5Hash: function Storage_calculateMD5Hash(aValue) {
+ let hash = gCryptoHash;
+ let value = gUnicodeConverter.convertToByteArray(aValue);
+
+ hash.init(hash.MD5);
+ hash.update(value, value.length);
+ return this._convertToHexString(hash.finish(false));
+ },
+
+ _convertToHexString: function Storage_convertToHexString(aData) {
+ let hex = "";
+ for (let i = 0; i < aData.length; i++)
+ hex += ("0" + aData.charCodeAt(i).toString(16)).slice(-2);
+ return hex;
+ },
+
+ /**
+ * For functions that take a noOverwrite option, OS.File throws an error if
+ * the target file exists and noOverwrite is true. We don't consider that an
+ * error, and we don't want such errors propagated.
+ *
+ * @param {aNoOverwrite} The noOverwrite option used in the OS.File operation.
+ *
+ * @return {function} A function that should be passed as the second argument
+ * to then() (the `onError` argument).
+ */
+ _eatNoOverwriteError: function Storage__eatNoOverwriteError(aNoOverwrite) {
+ return function onError(err) {
+ if (!aNoOverwrite ||
+ !(err instanceof OS.File.Error) ||
+ !err.becauseExists) {
+ throw err;
+ }
+ };
+ },
+
+ // Deprecated, please do not use
+ getFileForURL: function Storage_getFileForURL_DEPRECATED(aURL) {
+ Deprecated.warning("PageThumbs.getFileForURL is deprecated. Please use PageThumbs.getFilePathForURL and OS.File",
+ "https://developer.mozilla.org/docs/JavaScript_OS.File");
+ // Note: Once this method has been removed, we can get rid of the dependency towards FileUtils
+ return new FileUtils.File(PageThumbsStorage.getFilePathForURL(aURL));
+ }
+};
+
+var PageThumbsStorageMigrator = {
+ get currentVersion() {
+ try {
+ return Services.prefs.getIntPref(PREF_STORAGE_VERSION);
+ } catch (e) {
+ // The pref doesn't exist, yet. Return version 0.
+ return 0;
+ }
+ },
+
+ set currentVersion(aVersion) {
+ Services.prefs.setIntPref(PREF_STORAGE_VERSION, aVersion);
+ },
+
+ migrate: function Migrator_migrate() {
+ let version = this.currentVersion;
+
+ // Storage version 1 never made it to beta.
+ // At the time of writing only Windows had (ProfD != ProfLD) and we
+ // needed to move thumbnails from the roaming profile to the locale
+ // one so that they're not needlessly included in backups and/or
+ // written via SMB.
+
+ // Storage version 2 also never made it to beta.
+ // The thumbnail folder structure has been changed and old thumbnails
+ // were not migrated. Instead, we just renamed the current folder to
+ // "<name>-old" and will remove it later.
+
+ if (version < 3) {
+ this.migrateToVersion3();
+ }
+
+ this.currentVersion = LATEST_STORAGE_VERSION;
+ },
+
+ /**
+ * Bug 239254 added support for having the disk cache and thumbnail
+ * directories on a local path (i.e. ~/.cache/) under Linux. We'll first
+ * try to move the old thumbnails to their new location. If that's not
+ * possible (because ProfD might be on a different file system than
+ * ProfLD) we'll just discard them.
+ *
+ * @param {string*} local The path to the local profile directory.
+ * Used for testing. Default argument is good for all non-testing uses.
+ * @param {string*} roaming The path to the roaming profile directory.
+ * Used for testing. Default argument is good for all non-testing uses.
+ */
+ migrateToVersion3: function Migrator_migrateToVersion3(
+ local = OS.Constants.Path.localProfileDir,
+ roaming = OS.Constants.Path.profileDir) {
+ PageThumbsWorker.post(
+ "moveOrDeleteAllThumbnails",
+ [OS.Path.join(roaming, THUMBNAIL_DIRECTORY),
+ OS.Path.join(local, THUMBNAIL_DIRECTORY)]
+ );
+ }
+};
+
+var PageThumbsExpiration = {
+ _filters: [],
+
+ init: function Expiration_init() {
+ gUpdateTimerManager.registerTimer("browser-cleanup-thumbnails", this,
+ EXPIRATION_INTERVAL_SECS);
+ },
+
+ addFilter: function Expiration_addFilter(aFilter) {
+ this._filters.push(aFilter);
+ },
+
+ removeFilter: function Expiration_removeFilter(aFilter) {
+ let index = this._filters.indexOf(aFilter);
+ if (index > -1)
+ this._filters.splice(index, 1);
+ },
+
+ notify: function Expiration_notify(aTimer) {
+ let urls = [];
+ let filtersToWaitFor = this._filters.length;
+
+ let expire = function expire() {
+ this.expireThumbnails(urls);
+ }.bind(this);
+
+ // No registered filters.
+ if (!filtersToWaitFor) {
+ expire();
+ return;
+ }
+
+ function filterCallback(aURLs) {
+ urls = urls.concat(aURLs);
+ if (--filtersToWaitFor == 0)
+ expire();
+ }
+
+ for (let filter of this._filters) {
+ if (typeof filter == "function")
+ filter(filterCallback)
+ else
+ filter.filterForThumbnailExpiration(filterCallback);
+ }
+ },
+
+ expireThumbnails: function Expiration_expireThumbnails(aURLsToKeep) {
+ let keep = aURLsToKeep.map(url => PageThumbsStorage.getLeafNameForURL(url));
+ let msg = [
+ PageThumbsStorage.path,
+ keep,
+ EXPIRATION_MIN_CHUNK_SIZE
+ ];
+
+ return PageThumbsWorker.post(
+ "expireFilesInDirectory",
+ msg
+ );
+ }
+};
+
+/**
+ * Interface to a dedicated thread handling I/O
+ */
+var PageThumbsWorker = new BasePromiseWorker("resource://gre/modules/PageThumbsWorker.js");
+// As the PageThumbsWorker performs I/O, we can receive instances of
+// OS.File.Error, so we need to install a decoder.
+PageThumbsWorker.ExceptionHandlers["OS.File.Error"] = OS.File.Error.fromMsg;
+
+var PageThumbsHistoryObserver = {
+ onDeleteURI(aURI, aGUID) {
+ PageThumbsStorage.remove(aURI.spec);
+ },
+
+ onClearHistory() {
+ PageThumbsStorage.wipe();
+ },
+
+ onTitleChanged: function () {},
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onVisit: function () {},
+ onPageChanged: function () {},
+ onDeleteVisits: function () {},
+
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver,
+ Ci.nsISupportsWeakReference])
+};