/* 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 HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; const PREF_STORAGE_VERSION = "browser.pagethumbnails.storage_version"; const LATEST_STORAGE_VERSION = 2; const EXPIRATION_MIN_CHUNK_SIZE = 50; const EXPIRATION_INTERVAL_SECS = 3600; /** * Name of the directory in the profile that contains the thumbnails. */ const THUMBNAIL_DIRECTORY = "thumbnails"; /** * The default background color for page thumbnails. */ const THUMBNAIL_BG_COLOR = "#fff"; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 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; }); /** * 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() "moz-page-thumb", /** * The static host to use for thumbnail urls. */ get staticHost() "thumbnail", /** * The thumbnails' image type. */ get contentType() "image/png", init: function PageThumbs_init() { if (!this._initialized) { this._initialized = true; PlacesUtils.history.addObserver(PageThumbsHistoryObserver, false); // Migrate the underlying storage, if needed. PageThumbsStorageMigrator.migrate(); PageThumbsExpiration.init(); } }, uninit: function PageThumbs_uninit() { if (this._initialized) { this._initialized = false; PlacesUtils.history.removeObserver(PageThumbsHistoryObserver); } }, /** * 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); }, /** * Captures a thumbnail for the given window. * @param aWindow The DOM window to capture a thumbnail from. * @param aCallback The function to be called when the thumbnail has been * captured. The first argument will be the data stream * containing the image data. */ capture: function PageThumbs_capture(aWindow, aCallback) { if (!this._prefEnabled()) { return; } let canvas = this._createCanvas(); this.captureToCanvas(aWindow, canvas); // Fetch the canvas data on the next event loop tick so that we allow // some event processing in between drawing to the canvas and encoding // its data. We want to block the UI as short as possible. See bug 744100. Services.tm.currentThread.dispatch(function () { canvas.mozFetchAsStream(aCallback, this.contentType); }.bind(this), Ci.nsIThread.DISPATCH_NORMAL); }, /** * Captures a thumbnail from a given window and draws it to the given canvas. * @param aWindow The DOM window to capture a thumbnail from. * @param aCanvas The canvas to draw to. */ captureToCanvas: function PageThumbs_captureToCanvas(aWindow, aCanvas) { let telemetryCaptureTime = new Date(); let [sw, sh, scale] = this._determineCropSize(aWindow, aCanvas); let ctx = aCanvas.getContext("2d"); // Scale the canvas accordingly. ctx.scale(scale, scale); try { // Draw the window contents to the canvas. ctx.drawWindow(aWindow, 0, 0, sw, sh, THUMBNAIL_BG_COLOR, ctx.DRAWWINDOW_DO_NOT_FLUSH); } catch (e) { // We couldn't draw to the canvas for some reason. } let telemetry = Services.telemetry; telemetry.getHistogramById("FX_THUMBNAILS_CAPTURE_TIME_MS") .add(new Date() - telemetryCaptureTime); }, /** * 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 channel = aBrowser.docShell.currentDocumentChannel; let originalURL = channel.originalURI.spec; this.capture(aBrowser.contentWindow, function (aInputStream) { let telemetryStoreTime = new Date(); function finish(aSuccessful) { if (aSuccessful) { Services.telemetry.getHistogramById("FX_THUMBNAILS_STORE_TIME_MS") .add(new Date() - telemetryStoreTime); // 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 (url != originalURL) PageThumbsStorage.copy(url, originalURL); } if (aCallback) aCallback(aSuccessful); } PageThumbsStorage.write(url, aInputStream, finish); }); }, addExpirationFilter: function PageThumbs_addExpirationFilter(aFilter) { PageThumbsExpiration.addFilter(aFilter); }, removeExpirationFilter: function PageThumbs_removeExpirationFilter(aFilter) { PageThumbsExpiration.removeFilter(aFilter); }, /** * Determines the crop size for a given content window. * @param aWindow The content window. * @param aCanvas The target canvas. * @return An array containing width, height and scale. */ _determineCropSize: function PageThumbs_determineCropSize(aWindow, aCanvas) { let sw = aWindow.innerWidth; let sh = aWindow.innerHeight; let {width: thumbnailWidth, height: thumbnailHeight} = aCanvas; let scale = Math.min(Math.max(thumbnailWidth / sw, thumbnailHeight / sh), 1); let scaledWidth = sw * scale; let scaledHeight = sh * scale; if (scaledHeight > thumbnailHeight) sh -= Math.floor(Math.abs(scaledHeight - thumbnailHeight) * scale); if (scaledWidth > thumbnailWidth) sw -= Math.floor(Math.abs(scaledWidth - thumbnailWidth) * scale); return [sw, sh, scale]; }, /** * Creates a new hidden canvas element. * @return The newly created canvas. */ _createCanvas: function PageThumbs_createCanvas() { let doc = Services.appShell.hiddenDOMWindow.document; let canvas = doc.createElementNS(HTML_NAMESPACE, "canvas"); canvas.mozOpaque = true; canvas.mozImageSmoothingEnabled = true; let [thumbnailWidth, thumbnailHeight] = this._getThumbnailSize(); canvas.width = thumbnailWidth; canvas.height = thumbnailHeight; return canvas; }, /** * Calculates the thumbnail size based on current desktop's dimensions. * @return The calculated thumbnail size or a default if unable to calculate. */ _getThumbnailSize: function PageThumbs_getThumbnailSize() { if (!this._thumbnailWidth || !this._thumbnailHeight) { let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"] .getService(Ci.nsIScreenManager); let left = {}, top = {}, width = {}, height = {}; screenManager.primaryScreen.GetRect(left, top, width, height); this._thumbnailWidth = Math.round(width.value / 3); this._thumbnailHeight = Math.round(height.value / 3); } return [this._thumbnailWidth, this._thumbnailHeight]; }, _prefEnabled: function PageThumbs_prefEnabled() { try { return Services.prefs.getBoolPref("browser.pageThumbs.enabled"); } catch (e) { return true; } }, }; this.PageThumbsStorage = { getDirectory: function Storage_getDirectory(aCreate = true) { return FileUtils.getDir("ProfLD", [THUMBNAIL_DIRECTORY], aCreate); }, getLeafNameForURL: function Storage_getLeafNameForURL(aURL) { let hash = this._calculateMD5Hash(aURL); return hash + ".png"; }, getFileForURL: function Storage_getFileForURL(aURL) { let file = this.getDirectory(); file.append(this.getLeafNameForURL(aURL)); return file; }, write: function Storage_write(aURL, aDataStream, aCallback) { let file = this.getFileForURL(aURL); let fos = FileUtils.openSafeFileOutputStream(file); NetUtil.asyncCopy(aDataStream, fos, function (aResult) { FileUtils.closeSafeFileOutputStream(fos); aCallback(Components.isSuccessCode(aResult)); }); }, copy: function Storage_copy(aSourceURL, aTargetURL) { let sourceFile = this.getFileForURL(aSourceURL); let targetFile = this.getFileForURL(aTargetURL); try { sourceFile.copyTo(targetFile.parent, targetFile.leafName); } catch (e) { /* We might not be permitted to write to the file. */ } }, remove: function Storage_remove(aURL) { let file = this.getFileForURL(aURL); PageThumbsWorker.postMessage({type: "removeFile", path: file.path}); }, wipe: function Storage_wipe() { let dir = this.getDirectory(false); dir.followLinks = false; try { dir.remove(true); } catch (e) { /* The directory might not exist or we're not permitted to remove it. */ } }, _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; } }; let 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; if (version < 1) { this.removeThumbnailsFromRoamingProfile(); } if (version < 2) { this.renameThumbnailsFolder(); } this.currentVersion = LATEST_STORAGE_VERSION; }, removeThumbnailsFromRoamingProfile: function Migrator_removeThumbnailsFromRoamingProfile() { let local = FileUtils.getDir("ProfLD", [THUMBNAIL_DIRECTORY]); let roaming = FileUtils.getDir("ProfD", [THUMBNAIL_DIRECTORY]); if (!roaming.equals(local) && roaming.exists()) { roaming.followLinks = false; try { roaming.remove(true); } catch (e) { // The directory might not exist or we're not permitted to remove it. } } }, renameThumbnailsFolder: function Migrator_renameThumbnailsFolder() { let dir = FileUtils.getDir("ProfLD", [THUMBNAIL_DIRECTORY]); try { dir.moveTo(null, dir.leafName + "-old"); } catch (e) { // The directory might not exist or we're not permitted to rename it. } } }; let 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, aCallback) { PageThumbsWorker.postMessage({ type: "expireFilesInDirectory", minChunkSize: EXPIRATION_MIN_CHUNK_SIZE, path: PageThumbsStorage.getDirectory().path, filesToKeep: [PageThumbsStorage.getLeafNameForURL(url) for (url of aURLsToKeep)] }, aCallback); } }; /** * Interface to a dedicated thread handling I/O */ let PageThumbsWorker = { /** * A (fifo) queue of callbacks registered for execution * upon completion of calls to the worker. */ _callbacks: [], /** * Get the worker, spawning it if necessary. * Code of the worker is in companion file PageThumbsWorker.js */ get _worker() { delete this._worker; this._worker = new ChromeWorker("resource:///modules/PageThumbsWorker.js"); this._worker.addEventListener("message", this); return this._worker; }, /** * Post a message to the dedicated thread, registering a callback * to be executed once the reply has been received. * * See PageThumbsWorker.js for the format of messages and replies. * * @param {*} message A JSON message. * @param {Function=} callback An optional callback. */ postMessage: function Worker_postMessage(message, callback) { this._callbacks.push(callback); this._worker.postMessage(message); }, /** * Handle a message from the dedicated thread. */ handleEvent: function Worker_handleEvent(aEvent) { let callback = this._callbacks.shift(); if (callback) callback(aEvent.data); } }; let PageThumbsHistoryObserver = { onDeleteURI: function Thumbnails_onDeleteURI(aURI, aGUID) { PageThumbsStorage.remove(aURI.spec); }, onClearHistory: function Thumbnails_onClearHistory() { PageThumbsStorage.wipe(); }, onTitleChanged: function () {}, onBeginUpdateBatch: function () {}, onEndUpdateBatch: function () {}, onVisit: function () {}, onBeforeDeleteURI: function () {}, onPageChanged: function () {}, onDeleteVisits: function () {}, QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver]) };