diff options
Diffstat (limited to 'toolkit/components/thumbnails')
46 files changed, 4302 insertions, 0 deletions
diff --git a/toolkit/components/thumbnails/BackgroundPageThumbs.jsm b/toolkit/components/thumbnails/BackgroundPageThumbs.jsm new file mode 100644 index 000000000..fded51cea --- /dev/null +++ b/toolkit/components/thumbnails/BackgroundPageThumbs.jsm @@ -0,0 +1,495 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = [ + "BackgroundPageThumbs", +]; + +const DEFAULT_CAPTURE_TIMEOUT = 30000; // ms +const DESTROY_BROWSER_TIMEOUT = 60000; // ms +const FRAME_SCRIPT_URL = "chrome://global/content/backgroundPageThumbsContent.js"; + +const TELEMETRY_HISTOGRAM_ID_PREFIX = "FX_THUMBNAILS_BG_"; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +const ABOUT_NEWTAB_SEGREGATION_PREF = "privacy.usercontext.about_newtab_segregation.enabled"; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +Cu.import("resource://gre/modules/PageThumbs.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +// possible FX_THUMBNAILS_BG_CAPTURE_DONE_REASON_2 telemetry values +const TEL_CAPTURE_DONE_OK = 0; +const TEL_CAPTURE_DONE_TIMEOUT = 1; +// 2 and 3 were used when we had special handling for private-browsing. +const TEL_CAPTURE_DONE_CRASHED = 4; +const TEL_CAPTURE_DONE_BAD_URI = 5; + +// These are looked up on the global as properties below. +XPCOMUtils.defineConstant(this, "TEL_CAPTURE_DONE_OK", TEL_CAPTURE_DONE_OK); +XPCOMUtils.defineConstant(this, "TEL_CAPTURE_DONE_TIMEOUT", TEL_CAPTURE_DONE_TIMEOUT); +XPCOMUtils.defineConstant(this, "TEL_CAPTURE_DONE_CRASHED", TEL_CAPTURE_DONE_CRASHED); +XPCOMUtils.defineConstant(this, "TEL_CAPTURE_DONE_BAD_URI", TEL_CAPTURE_DONE_BAD_URI); + +XPCOMUtils.defineLazyModuleGetter(this, "ContextualIdentityService", + "resource://gre/modules/ContextualIdentityService.jsm"); +const global = this; + +const BackgroundPageThumbs = { + + /** + * Asynchronously captures a thumbnail of the given URL. + * + * The page is loaded anonymously, and plug-ins are disabled. + * + * @param url The URL to capture. + * @param options An optional object that configures the capture. Its + * properties are the following, and all are optional: + * @opt onDone A function that will be asynchronously called when the + * capture is complete or times out. It's called as + * onDone(url), + * where `url` is the captured URL. + * @opt timeout The capture will time out after this many milliseconds have + * elapsed after the capture has progressed to the head of + * the queue and started. Defaults to 30000 (30 seconds). + */ + capture: function (url, options={}) { + if (!PageThumbs._prefEnabled()) { + if (options.onDone) + schedule(() => options.onDone(url)); + return; + } + this._captureQueue = this._captureQueue || []; + this._capturesByURL = this._capturesByURL || new Map(); + + tel("QUEUE_SIZE_ON_CAPTURE", this._captureQueue.length); + + // We want to avoid duplicate captures for the same URL. If there is an + // existing one, we just add the callback to that one and we are done. + let existing = this._capturesByURL.get(url); + if (existing) { + if (options.onDone) + existing.doneCallbacks.push(options.onDone); + // The queue is already being processed, so nothing else to do... + return; + } + let cap = new Capture(url, this._onCaptureOrTimeout.bind(this), options); + this._captureQueue.push(cap); + this._capturesByURL.set(url, cap); + this._processCaptureQueue(); + }, + + /** + * Asynchronously captures a thumbnail of the given URL if one does not + * already exist. Otherwise does nothing. + * + * @param url The URL to capture. + * @param options An optional object that configures the capture. See + * capture() for description. + * @return {Promise} A Promise that resolves when this task completes + */ + captureIfMissing: Task.async(function* (url, options={}) { + // The fileExistsForURL call is an optimization, potentially but unlikely + // incorrect, and no big deal when it is. After the capture is done, we + // atomically test whether the file exists before writing it. + let exists = yield PageThumbsStorage.fileExistsForURL(url); + if (exists) { + if (options.onDone) { + options.onDone(url); + } + return url; + } + let thumbPromise = new Promise((resolve, reject) => { + function observe(subject, topic, data) { // jshint ignore:line + if (data === url) { + switch (topic) { + case "page-thumbnail:create": + resolve(); + break; + case "page-thumbnail:error": + reject(new Error("page-thumbnail:error")); + break; + } + Services.obs.removeObserver(observe, "page-thumbnail:create"); + Services.obs.removeObserver(observe, "page-thumbnail:error"); + } + } + Services.obs.addObserver(observe, "page-thumbnail:create", false); + Services.obs.addObserver(observe, "page-thumbnail:error", false); + }); + try { + this.capture(url, options); + yield thumbPromise; + } catch (err) { + if (options.onDone) { + options.onDone(url); + } + throw err; + } + return url; + }), + + /** + * Tell the service that the thumbnail browser should be recreated at next + * call of _ensureBrowser(). + */ + renewThumbnailBrowser: function() { + this._renewThumbBrowser = true; + }, + + /** + * Ensures that initialization of the thumbnail browser's parent window has + * begun. + * + * @return True if the parent window is completely initialized and can be + * used, and false if initialization has started but not completed. + */ + _ensureParentWindowReady: function () { + if (this._parentWin) + // Already fully initialized. + return true; + if (this._startedParentWinInit) + // Already started initializing. + return false; + + this._startedParentWinInit = true; + + // Create an html:iframe, stick it in the parent document, and + // use it to host the browser. about:blank will not have the system + // principal, so it can't host, but a document with a chrome URI will. + let hostWindow = Services.appShell.hiddenDOMWindow; + let iframe = hostWindow.document.createElementNS(HTML_NS, "iframe"); + iframe.setAttribute("src", "chrome://global/content/mozilla.xhtml"); + let onLoad = function onLoadFn() { + iframe.removeEventListener("load", onLoad, true); + this._parentWin = iframe.contentWindow; + this._processCaptureQueue(); + }.bind(this); + iframe.addEventListener("load", onLoad, true); + hostWindow.document.documentElement.appendChild(iframe); + this._hostIframe = iframe; + + return false; + }, + + /** + * Destroys the service. Queued and pending captures will never complete, and + * their consumer callbacks will never be called. + */ + _destroy: function () { + if (this._captureQueue) + this._captureQueue.forEach(cap => cap.destroy()); + this._destroyBrowser(); + if (this._hostIframe) + this._hostIframe.remove(); + delete this._captureQueue; + delete this._hostIframe; + delete this._startedParentWinInit; + delete this._parentWin; + }, + + /** + * Creates the thumbnail browser if it doesn't already exist. + */ + _ensureBrowser: function () { + if (this._thumbBrowser && !this._renewThumbBrowser) + return; + + this._destroyBrowser(); + this._renewThumbBrowser = false; + + let browser = this._parentWin.document.createElementNS(XUL_NS, "browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("remote", "true"); + browser.setAttribute("disableglobalhistory", "true"); + + if (Services.prefs.getBoolPref(ABOUT_NEWTAB_SEGREGATION_PREF)) { + // Use the private container for thumbnails. + let privateIdentity = + ContextualIdentityService.getPrivateIdentity("userContextIdInternal.thumbnail"); + browser.setAttribute("usercontextid", privateIdentity.userContextId); + } + + // Size the browser. Make its aspect ratio the same as the canvases' that + // the thumbnails are drawn into; the canvases' aspect ratio is the same as + // the screen's, so use that. Aim for a size in the ballpark of 1024x768. + let [swidth, sheight] = [{}, {}]; + Cc["@mozilla.org/gfx/screenmanager;1"]. + getService(Ci.nsIScreenManager). + primaryScreen. + GetRectDisplayPix({}, {}, swidth, sheight); + let bwidth = Math.min(1024, swidth.value); + // Setting the width and height attributes doesn't work -- the resulting + // thumbnails are blank and transparent -- but setting the style does. + browser.style.width = bwidth + "px"; + browser.style.height = (bwidth * sheight.value / swidth.value) + "px"; + + this._parentWin.document.documentElement.appendChild(browser); + + // an event that is sent if the remote process crashes - no need to remove + // it as we want it to be there as long as the browser itself lives. + browser.addEventListener("oop-browser-crashed", () => { + Cu.reportError("BackgroundThumbnails remote process crashed - recovering"); + this._destroyBrowser(); + let curCapture = this._captureQueue.length ? this._captureQueue[0] : null; + // we could retry the pending capture, but it's possible the crash + // was due directly to it, so trying again might just crash again. + // We could keep a flag to indicate if it previously crashed, but + // "resetting" the capture requires more work - so for now, we just + // discard it. + if (curCapture && curCapture.pending) { + // Continue queue processing by calling curCapture._done(). Do it after + // this crashed listener returns, though. A new browser will be created + // immediately (on the same stack as the _done call stack) if there are + // any more queued-up captures, and that seems to mess up the new + // browser's message manager if it happens on the same stack as the + // listener. Trying to send a message to the manager in that case + // throws NS_ERROR_NOT_INITIALIZED. + Services.tm.currentThread.dispatch(() => { + curCapture._done(null, TEL_CAPTURE_DONE_CRASHED); + }, Ci.nsIEventTarget.DISPATCH_NORMAL); + } + // else: we must have been idle and not currently doing a capture (eg, + // maybe a GC or similar crashed) - so there's no need to attempt a + // queue restart - the next capture request will set everything up. + }); + + browser.messageManager.loadFrameScript(FRAME_SCRIPT_URL, false); + this._thumbBrowser = browser; + }, + + _destroyBrowser: function () { + if (!this._thumbBrowser) + return; + this._thumbBrowser.remove(); + delete this._thumbBrowser; + }, + + /** + * Starts the next capture if the queue is not empty and the service is fully + * initialized. + */ + _processCaptureQueue: function () { + if (!this._captureQueue.length || + this._captureQueue[0].pending || + !this._ensureParentWindowReady()) + return; + + // Ready to start the first capture in the queue. + this._ensureBrowser(); + this._captureQueue[0].start(this._thumbBrowser.messageManager); + if (this._destroyBrowserTimer) { + this._destroyBrowserTimer.cancel(); + delete this._destroyBrowserTimer; + } + }, + + /** + * Called when the current capture completes or fails (eg, times out, remote + * process crashes.) + */ + _onCaptureOrTimeout: function (capture) { + // Since timeouts start as an item is being processed, only the first + // item in the queue can be passed to this method. + if (capture !== this._captureQueue[0]) + throw new Error("The capture should be at the head of the queue."); + this._captureQueue.shift(); + this._capturesByURL.delete(capture.url); + if (capture.doneReason != TEL_CAPTURE_DONE_OK) { + Services.obs.notifyObservers(null, "page-thumbnail:error", capture.url); + } + + // Start the destroy-browser timer *before* processing the capture queue. + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this._destroyBrowser.bind(this), + this._destroyBrowserTimeout, + Ci.nsITimer.TYPE_ONE_SHOT); + this._destroyBrowserTimer = timer; + + this._processCaptureQueue(); + }, + + _destroyBrowserTimeout: DESTROY_BROWSER_TIMEOUT, +}; + +Services.prefs.addObserver(ABOUT_NEWTAB_SEGREGATION_PREF, + function(aSubject, aTopic, aData) { + if (aTopic == "nsPref:changed" && aData == ABOUT_NEWTAB_SEGREGATION_PREF) { + BackgroundPageThumbs.renewThumbnailBrowser(); + } + }, + false); + +Object.defineProperty(this, "BackgroundPageThumbs", { + value: BackgroundPageThumbs, + enumerable: true, + writable: false +}); + +/** + * Represents a single capture request in the capture queue. + * + * @param url The URL to capture. + * @param captureCallback A function you want called when the capture + * completes. + * @param options The capture options. + */ +function Capture(url, captureCallback, options) { + this.url = url; + this.captureCallback = captureCallback; + this.options = options; + this.id = Capture.nextID++; + this.creationDate = new Date(); + this.doneCallbacks = []; + this.doneReason; + if (options.onDone) + this.doneCallbacks.push(options.onDone); +} + +Capture.prototype = { + + get pending() { + return !!this._msgMan; + }, + + /** + * Sends a message to the content script to start the capture. + * + * @param messageManager The nsIMessageSender of the thumbnail browser. + */ + start: function (messageManager) { + this.startDate = new Date(); + tel("CAPTURE_QUEUE_TIME_MS", this.startDate - this.creationDate); + + // timeout timer + let timeout = typeof(this.options.timeout) == "number" ? + this.options.timeout : + DEFAULT_CAPTURE_TIMEOUT; + this._timeoutTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this._timeoutTimer.initWithCallback(this, timeout, + Ci.nsITimer.TYPE_ONE_SHOT); + + // didCapture registration + this._msgMan = messageManager; + this._msgMan.sendAsyncMessage("BackgroundPageThumbs:capture", + { id: this.id, url: this.url }); + this._msgMan.addMessageListener("BackgroundPageThumbs:didCapture", this); + }, + + /** + * The only intended external use of this method is by the service when it's + * uninitializing and doing things like destroying the thumbnail browser. In + * that case the consumer's completion callback will never be called. + */ + destroy: function () { + // This method may be called for captures that haven't started yet, so + // guard against not yet having _timeoutTimer, _msgMan etc properties... + if (this._timeoutTimer) { + this._timeoutTimer.cancel(); + delete this._timeoutTimer; + } + if (this._msgMan) { + this._msgMan.removeMessageListener("BackgroundPageThumbs:didCapture", + this); + delete this._msgMan; + } + delete this.captureCallback; + delete this.doneCallbacks; + delete this.options; + }, + + // Called when the didCapture message is received. + receiveMessage: function (msg) { + if (msg.data.imageData) + tel("CAPTURE_SERVICE_TIME_MS", new Date() - this.startDate); + + // A different timed-out capture may have finally successfully completed, so + // discard messages that aren't meant for this capture. + if (msg.data.id != this.id) + return; + + if (msg.data.failReason) { + let reason = global["TEL_CAPTURE_DONE_" + msg.data.failReason]; + this._done(null, reason); + return; + } + + this._done(msg.data, TEL_CAPTURE_DONE_OK); + }, + + // Called when the timeout timer fires. + notify: function () { + this._done(null, TEL_CAPTURE_DONE_TIMEOUT); + }, + + _done: function (data, reason) { + // Note that _done will be called only once, by either receiveMessage or + // notify, since it calls destroy here, which cancels the timeout timer and + // removes the didCapture message listener. + let { captureCallback, doneCallbacks, options } = this; + this.destroy(); + this.doneReason = reason; + + if (typeof(reason) != "number") { + throw new Error("A done reason must be given."); + } + tel("CAPTURE_DONE_REASON_2", reason); + if (data && data.telemetry) { + // Telemetry is currently disabled in the content process (bug 680508). + for (let id in data.telemetry) { + tel(id, data.telemetry[id]); + } + } + + let done = () => { + captureCallback(this); + for (let callback of doneCallbacks) { + try { + callback.call(options, this.url); + } + catch (err) { + Cu.reportError(err); + } + } + + if (Services.prefs.getBoolPref(ABOUT_NEWTAB_SEGREGATION_PREF)) { + // Clear the data in the private container for thumbnails. + let privateIdentity = + ContextualIdentityService.getPrivateIdentity("userContextIdInternal.thumbnail"); + Services.obs.notifyObservers(null, "clear-origin-attributes-data", + JSON.stringify({ userContextId: privateIdentity.userContextId })); + } + }; + + if (!data) { + done(); + return; + } + + PageThumbs._store(this.url, data.finalURL, data.imageData, true) + .then(done, done); + }, +}; + +Capture.nextID = 0; + +/** + * Adds a value to one of this module's telemetry histograms. + * + * @param histogramID This is prefixed with this module's ID. + * @param value The value to add. + */ +function tel(histogramID, value) { + let id = TELEMETRY_HISTOGRAM_ID_PREFIX + histogramID; + Services.telemetry.getHistogramById(id).add(value); +} + +function schedule(callback) { + Services.tm.mainThread.dispatch(callback, Ci.nsIThread.DISPATCH_NORMAL); +} diff --git a/toolkit/components/thumbnails/BrowserPageThumbs.manifest b/toolkit/components/thumbnails/BrowserPageThumbs.manifest new file mode 100644 index 000000000..8dfc0597b --- /dev/null +++ b/toolkit/components/thumbnails/BrowserPageThumbs.manifest @@ -0,0 +1,2 @@ +component {5a4ae9b5-f475-48ae-9dce-0b4c1d347884} PageThumbsProtocol.js +contract @mozilla.org/network/protocol;1?name=moz-page-thumb {5a4ae9b5-f475-48ae-9dce-0b4c1d347884} diff --git a/toolkit/components/thumbnails/PageThumbUtils.jsm b/toolkit/components/thumbnails/PageThumbUtils.jsm new file mode 100644 index 000000000..dda3a81b3 --- /dev/null +++ b/toolkit/components/thumbnails/PageThumbUtils.jsm @@ -0,0 +1,354 @@ +/* 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/. */ + +/* + * Common thumbnailing routines used by various consumers, including + * PageThumbs and backgroundPageThumbsContent. + */ + +this.EXPORTED_SYMBOLS = ["PageThumbUtils"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Promise.jsm", this); +Cu.import("resource://gre/modules/AppConstants.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm"); + +this.PageThumbUtils = { + // The default background color for page thumbnails. + THUMBNAIL_BG_COLOR: "#fff", + // The namespace for thumbnail canvas elements. + HTML_NAMESPACE: "http://www.w3.org/1999/xhtml", + + /** + * Creates a new canvas element in the context of aWindow, or if aWindow + * is undefined, in the context of hiddenDOMWindow. + * + * @param aWindow (optional) The document of this window will be used to + * create the canvas. If not given, the hidden window will be used. + * @param aWidth (optional) width of the canvas to create + * @param aHeight (optional) height of the canvas to create + * @return The newly created canvas. + */ + createCanvas: function (aWindow, aWidth = 0, aHeight = 0) { + let doc = (aWindow || Services.appShell.hiddenDOMWindow).document; + let canvas = doc.createElementNS(this.HTML_NAMESPACE, "canvas"); + canvas.mozOpaque = true; + canvas.imageSmoothingEnabled = true; + let [thumbnailWidth, thumbnailHeight] = this.getThumbnailSize(aWindow); + canvas.width = aWidth ? aWidth : thumbnailWidth; + canvas.height = aHeight ? aHeight : thumbnailHeight; + return canvas; + }, + + /** + * Calculates a preferred initial thumbnail size based based on newtab.css + * sizes or a preference for other applications. The sizes should be the same + * as set for the tile sizes in newtab. + * + * @param aWindow (optional) aWindow that is used to calculate the scaling size. + * @return The calculated thumbnail size or a default if unable to calculate. + */ + getThumbnailSize: function (aWindow = null) { + if (!this._thumbnailWidth || !this._thumbnailHeight) { + let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"] + .getService(Ci.nsIScreenManager); + let left = {}, top = {}, screenWidth = {}, screenHeight = {}; + screenManager.primaryScreen.GetRectDisplayPix(left, top, screenWidth, screenHeight); + + /** * + * The system default scale might be different than + * what is reported by the window. For example, + * retina displays have 1:1 system scales, but 2:1 window + * scale as 1 pixel system wide == 2 device pixels. + * To get the best image quality, query both and take the highest one. + */ + let systemScale = screenManager.systemDefaultScale; + let windowScale = aWindow ? aWindow.devicePixelRatio : systemScale; + let scale = Math.max(systemScale, windowScale); + + /** * + * On retina displays, we can sometimes go down this path + * without a window object. In those cases, force 2x scaling + * as the system scale doesn't represent the 2x scaling + * on OS X. + */ + if (AppConstants.platform == "macosx" && !aWindow) { + scale = 2; + } + + /** * + * THESE VALUES ARE DEFINED IN newtab.css and hard coded. + * If you change these values from the prefs, + * ALSO CHANGE THEM IN newtab.css + */ + let prefWidth = Services.prefs.getIntPref("toolkit.pageThumbs.minWidth"); + let prefHeight = Services.prefs.getIntPref("toolkit.pageThumbs.minHeight"); + let divisor = Services.prefs.getIntPref("toolkit.pageThumbs.screenSizeDivisor"); + + prefWidth *= scale; + prefHeight *= scale; + + this._thumbnailWidth = Math.max(Math.round(screenWidth.value / divisor), prefWidth); + this._thumbnailHeight = Math.max(Math.round(screenHeight.value / divisor), prefHeight); + } + + return [this._thumbnailWidth, this._thumbnailHeight]; + }, + + /** * + * Given a browser window, return the size of the content + * minus the scroll bars. + */ + getContentSize: function(aWindow) { + let utils = aWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + // aWindow may be a cpow, add exposed props security values. + let sbWidth = {}, sbHeight = {}; + + try { + utils.getScrollbarSize(false, sbWidth, sbHeight); + } catch (e) { + // This might fail if the window does not have a presShell. + Cu.reportError("Unable to get scrollbar size in determineCropSize."); + sbWidth.value = sbHeight.value = 0; + } + + // Even in RTL mode, scrollbars are always on the right. + // So there's no need to determine a left offset. + let width = aWindow.innerWidth - sbWidth.value; + let height = aWindow.innerHeight - sbHeight.value; + + return [width, height]; + }, + + /** * + * Given a browser window, this creates a snapshot of the content + * and returns a canvas with the resulting snapshot of the content + * at the thumbnail size. It has to do this through a two step process: + * + * 1) Render the content at the window size to a canvas that is 2x the thumbnail size + * 2) Downscale the canvas from (1) down to the thumbnail size + * + * This is because the thumbnail size is too small to render at directly, + * causing pages to believe the browser is a small resolution. Also, + * at that resolution, graphical artifacts / text become very jagged. + * It's actually better to the eye to have small blurry text than sharp + * jagged pixels to represent text. + * + * @params aWindow - the window to create a snapshot of. + * @params aDestCanvas destination canvas to draw the final + * snapshot to. Can be null. + * @param aArgs (optional) Additional named parameters: + * fullScale - request that a non-downscaled image be returned. + * @return Canvas with a scaled thumbnail of the window. + */ + createSnapshotThumbnail: function(aWindow, aDestCanvas, aArgs) { + if (Cu.isCrossProcessWrapper(aWindow)) { + throw new Error('Do not pass cpows here.'); + } + let fullScale = aArgs ? aArgs.fullScale : false; + let [contentWidth, contentHeight] = this.getContentSize(aWindow); + let [thumbnailWidth, thumbnailHeight] = aDestCanvas ? + [aDestCanvas.width, aDestCanvas.height] : + this.getThumbnailSize(aWindow); + + // If the caller wants a fullscale image, set the desired thumbnail dims + // to the dims of content and (if provided) size the incoming canvas to + // support our results. + if (fullScale) { + thumbnailWidth = contentWidth; + thumbnailHeight = contentHeight; + if (aDestCanvas) { + aDestCanvas.width = contentWidth; + aDestCanvas.height = contentHeight; + } + } + + let intermediateWidth = thumbnailWidth * 2; + let intermediateHeight = thumbnailHeight * 2; + let skipDownscale = false; + + // If the intermediate thumbnail is larger than content dims (hiDPI + // devices can experience this) or a full preview is requested render + // at the final thumbnail size. + if ((intermediateWidth >= contentWidth || + intermediateHeight >= contentHeight) || fullScale) { + intermediateWidth = thumbnailWidth; + intermediateHeight = thumbnailHeight; + skipDownscale = true; + } + + // Create an intermediate surface + let snapshotCanvas = this.createCanvas(aWindow, intermediateWidth, + intermediateHeight); + + // Step 1: capture the image at the intermediate dims. For thumbnails + // this is twice the thumbnail size, for fullScale images this is at + // content dims. + // Also by default, canvas does not draw the scrollbars, so no need to + // remove the scrollbar sizes. + let scale = Math.min(Math.max(intermediateWidth / contentWidth, + intermediateHeight / contentHeight), 1); + + let snapshotCtx = snapshotCanvas.getContext("2d"); + snapshotCtx.save(); + snapshotCtx.scale(scale, scale); + snapshotCtx.drawWindow(aWindow, 0, 0, contentWidth, contentHeight, + PageThumbUtils.THUMBNAIL_BG_COLOR, + snapshotCtx.DRAWWINDOW_DO_NOT_FLUSH); + snapshotCtx.restore(); + + // Part 2: Downscale from our intermediate dims to the final thumbnail + // dims and copy the result to aDestCanvas. If the caller didn't + // provide a target canvas, create a new canvas and return it. + let finalCanvas = aDestCanvas || + this.createCanvas(aWindow, thumbnailWidth, thumbnailHeight); + + let finalCtx = finalCanvas.getContext("2d"); + finalCtx.save(); + if (!skipDownscale) { + finalCtx.scale(0.5, 0.5); + } + finalCtx.drawImage(snapshotCanvas, 0, 0); + finalCtx.restore(); + + return finalCanvas; + }, + + /** + * Determine a good thumbnail crop size and scale 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 (aWindow, aCanvas) { + if (Cu.isCrossProcessWrapper(aWindow)) { + throw new Error('Do not pass cpows here.'); + } + let utils = aWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + // aWindow may be a cpow, add exposed props security values. + let sbWidth = {}, sbHeight = {}; + + try { + utils.getScrollbarSize(false, sbWidth, sbHeight); + } catch (e) { + // This might fail if the window does not have a presShell. + Cu.reportError("Unable to get scrollbar size in determineCropSize."); + sbWidth.value = sbHeight.value = 0; + } + + // Even in RTL mode, scrollbars are always on the right. + // So there's no need to determine a left offset. + let width = aWindow.innerWidth - sbWidth.value; + let height = aWindow.innerHeight - sbHeight.value; + + let {width: thumbnailWidth, height: thumbnailHeight} = aCanvas; + let scale = Math.min(Math.max(thumbnailWidth / width, thumbnailHeight / height), 1); + let scaledWidth = width * scale; + let scaledHeight = height * scale; + + if (scaledHeight > thumbnailHeight) + height -= Math.floor(Math.abs(scaledHeight - thumbnailHeight) * scale); + + if (scaledWidth > thumbnailWidth) + width -= Math.floor(Math.abs(scaledWidth - thumbnailWidth) * scale); + + return [width, height, scale]; + }, + + shouldStoreContentThumbnail: function (aDocument, aDocShell) { + if (BrowserUtils.isToolbarVisible(aDocShell, "findbar")) { + return false; + } + + // FIXME Bug 720575 - Don't capture thumbnails for SVG or XML documents as + // that currently regresses Talos SVG tests. + if (aDocument instanceof Ci.nsIDOMXMLDocument) { + return false; + } + + let webNav = aDocShell.QueryInterface(Ci.nsIWebNavigation); + + // Don't take screenshots of about: pages. + if (webNav.currentURI.schemeIs("about")) { + return false; + } + + // There's no point in taking screenshot of loading pages. + if (aDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) { + return false; + } + + let channel = aDocShell.currentDocumentChannel; + + // No valid document channel. We shouldn't take a screenshot. + if (!channel) { + return false; + } + + // Don't take screenshots of internally redirecting about: pages. + // This includes error pages. + let uri = channel.originalURI; + if (uri.schemeIs("about")) { + return false; + } + + let httpChannel; + try { + httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + } catch (e) { /* Not an HTTP channel. */ } + + if (httpChannel) { + // Continue only if we have a 2xx status code. + try { + if (Math.floor(httpChannel.responseStatus / 100) != 2) { + return false; + } + } catch (e) { + // Can't get response information from the httpChannel + // because mResponseHead is not available. + return false; + } + + // Cache-Control: no-store. + if (httpChannel.isNoStoreResponse()) { + return false; + } + + // Don't capture HTTPS pages unless the user explicitly enabled it. + if (uri.schemeIs("https") && + !Services.prefs.getBoolPref("browser.cache.disk_cache_ssl")) { + return false; + } + } // httpChannel + return true; + }, + + /** + * Given a channel, returns true if it should be considered an "error + * response", false otherwise. + */ + isChannelErrorResponse: function(channel) { + // No valid document channel sounds like an error to me! + if (!channel) + return true; + if (!(channel instanceof Ci.nsIHttpChannel)) + // it might be FTP etc, so assume it's ok. + return false; + try { + return !channel.requestSucceeded; + } catch (_) { + // not being able to determine success is surely failure! + return true; + } + }, +}; diff --git a/toolkit/components/thumbnails/PageThumbs.jsm b/toolkit/components/thumbnails/PageThumbs.jsm new file mode 100644 index 000000000..9bd3ae4b3 --- /dev/null +++ b/toolkit/components/thumbnails/PageThumbs.jsm @@ -0,0 +1,901 @@ +/* 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 telemetryCaptureTime = new Date(); + let args = { + fullScale: aArgs ? aArgs.fullScale : false + }; + this._captureToCanvas(aBrowser, aCanvas, args, (aCanvas) => { + Services.telemetry + .getHistogramById("FX_THUMBNAILS_CAPTURE_TIME_MS") + .add(new Date() - telemetryCaptureTime); + 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* () { + let telemetryStoreTime = new Date(); + yield PageThumbsStorage.writeData(aFinalURL, aData, aNoOverwrite); + Services.telemetry.getHistogramById("FX_THUMBNAILS_STORE_TIME_MS") + .add(new Date() - telemetryStoreTime); + + 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(); + aData = new Uint8Array(aData); + 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]) +}; diff --git a/toolkit/components/thumbnails/PageThumbsProtocol.js b/toolkit/components/thumbnails/PageThumbsProtocol.js new file mode 100644 index 000000000..41dfe96be --- /dev/null +++ b/toolkit/components/thumbnails/PageThumbsProtocol.js @@ -0,0 +1,154 @@ +/* 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/. */ + +/** + * PageThumbsProtocol.js + * + * This file implements the moz-page-thumb:// protocol and the corresponding + * channel delivering cached thumbnails. + * + * URL structure: + * + * moz-page-thumb://thumbnail/?url=http%3A%2F%2Fwww.mozilla.org%2F&revision=XX + * + * This URL requests an image for 'http://www.mozilla.org/'. + * The value of the revision key may change when the stored thumbnail changes. + */ + +"use strict"; + +const Cu = Components.utils; +const Cc = Components.classes; +const Cr = Components.results; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/PageThumbs.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/osfile.jsm", this); + +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); + +const SUBSTITUTING_URL_CID = "{dea9657c-18cf-4984-bde9-ccef5d8ab473}"; + +/** + * Implements the thumbnail protocol handler responsible for moz-page-thumb: URLs. + */ +function Protocol() { +} + +Protocol.prototype = { + /** + * The scheme used by this protocol. + */ + get scheme() { + return PageThumbs.scheme; + }, + + /** + * The default port for this protocol (we don't support ports). + */ + get defaultPort() { + return -1; + }, + + /** + * The flags specific to this protocol implementation. + */ + get protocolFlags() { + return Ci.nsIProtocolHandler.URI_DANGEROUS_TO_LOAD | + Ci.nsIProtocolHandler.URI_IS_LOCAL_RESOURCE | + Ci.nsIProtocolHandler.URI_NORELATIVE | + Ci.nsIProtocolHandler.URI_NOAUTH; + }, + + /** + * Creates a new URI object that is suitable for loading by this protocol. + * @param aSpec The URI string in UTF8 encoding. + * @param aOriginCharset The charset of the document from which the URI originated. + * @return The newly created URI. + */ + newURI: function Proto_newURI(aSpec, aOriginCharset) { + let uri = Components.classesByID[SUBSTITUTING_URL_CID].createInstance(Ci.nsIURL); + uri.spec = aSpec; + return uri; + }, + + /** + * Constructs a new channel from the given URI for this protocol handler. + * @param aURI The URI for which to construct a channel. + * @param aLoadInfo The Loadinfo which to use on the channel. + * @return The newly created channel. + */ + newChannel2: function Proto_newChannel2(aURI, aLoadInfo) { + let {file} = aURI.QueryInterface(Ci.nsIFileURL); + let fileuri = Services.io.newFileURI(file); + let channel = Services.io.newChannelFromURIWithLoadInfo(fileuri, aLoadInfo); + channel.originalURI = aURI; + return channel; + }, + + newChannel: function Proto_newChannel(aURI) { + return this.newChannel2(aURI, null); + }, + + /** + * Decides whether to allow a blacklisted port. + * @return Always false, we'll never allow ports. + */ + allowPort: () => false, + + // nsISubstitutingProtocolHandler methods + + /* + * Substituting the scheme and host isn't enough, we also transform the path. + * So declare no-op implementations for (get|set|has)Substitution methods and + * do all the work in resolveURI. + */ + + setSubstitution(root, baseURI) {}, + + getSubstitution(root) { + throw Cr.NS_ERROR_NOT_AVAILABLE; + }, + + hasSubstitution(root) { + return false; + }, + + resolveURI(resURI) { + let {url} = parseURI(resURI); + let path = PageThumbsStorage.getFilePathForURL(url); + return OS.Path.toFileURI(path); + }, + + // xpcom machinery + classID: Components.ID("{5a4ae9b5-f475-48ae-9dce-0b4c1d347884}"), + QueryInterface: XPCOMUtils.generateQI([Ci.nsIProtocolHandler, + Ci.nsISubstitutingProtocolHandler]) +}; + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([Protocol]); + +/** + * Parses a given URI and extracts all parameters relevant to this protocol. + * @param aURI The URI to parse. + * @return The parsed parameters. + */ +function parseURI(aURI) { + if (aURI.host != PageThumbs.staticHost) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + let {query} = aURI.QueryInterface(Ci.nsIURL); + let params = {}; + + query.split("&").forEach(function (aParam) { + let [key, value] = aParam.split("=").map(decodeURIComponent); + params[key.toLowerCase()] = value; + }); + + return params; +} diff --git a/toolkit/components/thumbnails/PageThumbsWorker.js b/toolkit/components/thumbnails/PageThumbsWorker.js new file mode 100644 index 000000000..83171c91f --- /dev/null +++ b/toolkit/components/thumbnails/PageThumbsWorker.js @@ -0,0 +1,176 @@ +/* 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/. */ + +/** + * A worker dedicated for the I/O component of PageThumbs storage. + * + * Do not rely on the API of this worker. In a future version, it might be + * fully replaced by a OS.File global I/O worker. + */ + +"use strict"; + +importScripts("resource://gre/modules/osfile.jsm"); + +var PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js"); + +var File = OS.File; +var Type = OS.Shared.Type; + +var worker = new PromiseWorker.AbstractWorker(); +worker.dispatch = function(method, args = []) { + return Agent[method](...args); +}; +worker.postMessage = function(message, ...transfers) { + self.postMessage(message, ...transfers); +}; +worker.close = function() { + self.close(); +}; + +self.addEventListener("message", msg => worker.handleMessage(msg)); + + +var Agent = { + // Checks if the specified file exists and has an age less than as + // specifed (in seconds). + isFileRecent: function Agent_isFileRecent(path, maxAge) { + try { + let stat = OS.File.stat(path); + let maxDate = new Date(); + maxDate.setSeconds(maxDate.getSeconds() - maxAge); + return stat.lastModificationDate > maxDate; + } catch (ex) { + if (!(ex instanceof OS.File.Error)) { + throw ex; + } + // file doesn't exist (or can't be stat'd) - must be stale. + return false; + } + }, + + remove: function Agent_removeFile(path) { + try { + OS.File.remove(path); + return true; + } catch (e) { + return false; + } + }, + + expireFilesInDirectory: + function Agent_expireFilesInDirectory(path, filesToKeep, minChunkSize) { + let entries = this.getFileEntriesInDirectory(path, filesToKeep); + let limit = Math.max(minChunkSize, Math.round(entries.length / 2)); + + for (let entry of entries) { + this.remove(entry.path); + + // Check if we reached the limit of files to remove. + if (--limit <= 0) { + break; + } + } + + return true; + }, + + getFileEntriesInDirectory: + function Agent_getFileEntriesInDirectory(path, skipFiles) { + let iter = new OS.File.DirectoryIterator(path); + try { + if (!iter.exists()) { + return []; + } + + let skip = new Set(skipFiles); + + let entries = []; + for (let entry in iter) { + if (!entry.isDir && !entry.isSymLink && !skip.has(entry.name)) { + entries.push(entry); + } + } + return entries; + } finally { + iter.close(); + } + }, + + moveOrDeleteAllThumbnails: + function Agent_moveOrDeleteAllThumbnails(pathFrom, pathTo) { + OS.File.makeDir(pathTo, {ignoreExisting: true}); + if (pathFrom == pathTo) { + return true; + } + let iter = new OS.File.DirectoryIterator(pathFrom); + if (iter.exists()) { + for (let entry in iter) { + if (entry.isDir || entry.isSymLink) { + continue; + } + + + let from = OS.Path.join(pathFrom, entry.name); + let to = OS.Path.join(pathTo, entry.name); + + try { + OS.File.move(from, to, {noOverwrite: true, noCopy: true}); + } catch (e) { + OS.File.remove(from); + } + } + } + iter.close(); + + try { + OS.File.removeEmptyDir(pathFrom); + } catch (e) { + // This could fail if there's something in + // the folder we're not permitted to remove. + } + + return true; + }, + + writeAtomic: function Agent_writeAtomic(path, buffer, options) { + return File.writeAtomic(path, + buffer, + options); + }, + + makeDir: function Agent_makeDir(path, options) { + return File.makeDir(path, options); + }, + + copy: function Agent_copy(source, dest, options) { + return File.copy(source, dest, options); + }, + + wipe: function Agent_wipe(path) { + let iterator = new File.DirectoryIterator(path); + try { + for (let entry in iterator) { + try { + File.remove(entry.path); + } catch (ex) { + // If a file cannot be removed, we should still continue. + // This can happen at least for any of the following reasons: + // - access denied; + // - file has been removed recently during a previous wipe + // and the file system has not flushed that yet (yes, this + // can happen under Windows); + // - file has been removed by the user or another process. + } + } + } finally { + iterator.close(); + } + }, + + exists: function Agent_exists(path) { + return File.exists(path); + }, +}; + diff --git a/toolkit/components/thumbnails/content/backgroundPageThumbsContent.js b/toolkit/components/thumbnails/content/backgroundPageThumbsContent.js new file mode 100644 index 000000000..2103833b7 --- /dev/null +++ b/toolkit/components/thumbnails/content/backgroundPageThumbsContent.js @@ -0,0 +1,205 @@ +/* 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/. */ + +var { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.importGlobalProperties(['Blob', 'FileReader']); + +Cu.import("resource://gre/modules/PageThumbUtils.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +const STATE_LOADING = 1; +const STATE_CAPTURING = 2; +const STATE_CANCELED = 3; + +// NOTE: Copied from nsSandboxFlags.h +/** + * This flag prevents content from creating new auxiliary browsing contexts, + * e.g. using the target attribute, the window.open() method, or the + * showModalDialog() method. + */ +const SANDBOXED_AUXILIARY_NAVIGATION = 0x2; + +const backgroundPageThumbsContent = { + + init: function () { + Services.obs.addObserver(this, "document-element-inserted", true); + + // We want a low network priority for this service - lower than b/g tabs + // etc - so set it to the lowest priority available. + this._webNav.QueryInterface(Ci.nsIDocumentLoader). + loadGroup.QueryInterface(Ci.nsISupportsPriority). + priority = Ci.nsISupportsPriority.PRIORITY_LOWEST; + + docShell.allowMedia = false; + docShell.allowPlugins = false; + docShell.allowContentRetargeting = false; + let defaultFlags = Ci.nsIRequest.LOAD_ANONYMOUS | + Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING | + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY; + docShell.defaultLoadFlags = defaultFlags; + docShell.sandboxFlags |= SANDBOXED_AUXILIARY_NAVIGATION; + + addMessageListener("BackgroundPageThumbs:capture", + this._onCapture.bind(this)); + docShell. + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIWebProgress). + addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW); + }, + + observe: function (subj, topic, data) { + // Arrange to prevent (most) popup dialogs for this window - popups done + // in the parent (eg, auth) aren't prevented, but alert() etc are. + // disableDialogs only works on the current inner window, so it has + // to be called every page load, but before scripts run. + if (content && subj == content.document) { + content. + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDOMWindowUtils). + disableDialogs(); + } + }, + + get _webNav() { + return docShell.QueryInterface(Ci.nsIWebNavigation); + }, + + _onCapture: function (msg) { + this._nextCapture = { + id: msg.data.id, + url: msg.data.url, + }; + if (this._currentCapture) { + if (this._state == STATE_LOADING) { + // Cancel the current capture. + this._state = STATE_CANCELED; + this._loadAboutBlank(); + } + // Let the current capture finish capturing, or if it was just canceled, + // wait for onStateChange due to the about:blank load. + return; + } + this._startNextCapture(); + }, + + _startNextCapture: function () { + if (!this._nextCapture) + return; + this._currentCapture = this._nextCapture; + delete this._nextCapture; + this._state = STATE_LOADING; + this._currentCapture.pageLoadStartDate = new Date(); + + try { + this._webNav.loadURI(this._currentCapture.url, + Ci.nsIWebNavigation.LOAD_FLAGS_STOP_CONTENT, + null, null, null); + } catch (e) { + this._failCurrentCapture("BAD_URI"); + delete this._currentCapture; + this._startNextCapture(); + } + }, + + onStateChange: function (webProgress, req, flags, status) { + if (webProgress.isTopLevel && + (flags & Ci.nsIWebProgressListener.STATE_STOP) && + this._currentCapture) { + if (req.name == "about:blank") { + if (this._state == STATE_CAPTURING) { + // about:blank has loaded, ending the current capture. + this._finishCurrentCapture(); + delete this._currentCapture; + this._startNextCapture(); + } + else if (this._state == STATE_CANCELED) { + delete this._currentCapture; + this._startNextCapture(); + } + } + else if (this._state == STATE_LOADING && + Components.isSuccessCode(status)) { + // The requested page has loaded. Capture it. + this._state = STATE_CAPTURING; + this._captureCurrentPage(); + } + else if (this._state != STATE_CANCELED) { + // Something went wrong. Cancel the capture. Loading about:blank + // while onStateChange is still on the stack does not actually stop + // the request if it redirects, so do it asyncly. + this._state = STATE_CANCELED; + if (!this._cancelTimer) { + this._cancelTimer = + Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this._cancelTimer.init(() => { + this._loadAboutBlank(); + delete this._cancelTimer; + }, 0, Ci.nsITimer.TYPE_ONE_SHOT); + } + } + } + }, + + _captureCurrentPage: function () { + let capture = this._currentCapture; + capture.finalURL = this._webNav.currentURI.spec; + capture.pageLoadTime = new Date() - capture.pageLoadStartDate; + + let canvasDrawDate = new Date(); + + let finalCanvas = PageThumbUtils.createSnapshotThumbnail(content, null); + capture.canvasDrawTime = new Date() - canvasDrawDate; + + finalCanvas.toBlob(blob => { + capture.imageBlob = new Blob([blob]); + // Load about:blank to finish the capture and wait for onStateChange. + this._loadAboutBlank(); + }); + }, + + _finishCurrentCapture: function () { + let capture = this._currentCapture; + let fileReader = new FileReader(); + fileReader.onloadend = () => { + sendAsyncMessage("BackgroundPageThumbs:didCapture", { + id: capture.id, + imageData: fileReader.result, + finalURL: capture.finalURL, + telemetry: { + CAPTURE_PAGE_LOAD_TIME_MS: capture.pageLoadTime, + CAPTURE_CANVAS_DRAW_TIME_MS: capture.canvasDrawTime, + }, + }); + }; + fileReader.readAsArrayBuffer(capture.imageBlob); + }, + + _failCurrentCapture: function (reason) { + let capture = this._currentCapture; + sendAsyncMessage("BackgroundPageThumbs:didCapture", { + id: capture.id, + failReason: reason, + }); + }, + + // We load about:blank to finish all captures, even canceled captures. Two + // reasons: GC the captured page, and ensure it can't possibly load any more + // resources. + _loadAboutBlank: function _loadAboutBlank() { + this._webNav.loadURI("about:blank", + Ci.nsIWebNavigation.LOAD_FLAGS_STOP_CONTENT, + null, null, null); + }, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference, + Ci.nsIObserver, + ]), +}; + +backgroundPageThumbsContent.init(); diff --git a/toolkit/components/thumbnails/jar.mn b/toolkit/components/thumbnails/jar.mn new file mode 100644 index 000000000..c83c64e48 --- /dev/null +++ b/toolkit/components/thumbnails/jar.mn @@ -0,0 +1,6 @@ +# 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/. + +toolkit.jar: + content/global/backgroundPageThumbsContent.js (content/backgroundPageThumbsContent.js) diff --git a/toolkit/components/thumbnails/moz.build b/toolkit/components/thumbnails/moz.build new file mode 100644 index 000000000..9bc218b6a --- /dev/null +++ b/toolkit/components/thumbnails/moz.build @@ -0,0 +1,22 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +BROWSER_CHROME_MANIFESTS += ['test/browser.ini'] +XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell.ini'] + +EXTRA_COMPONENTS += [ + 'BrowserPageThumbs.manifest', + 'PageThumbsProtocol.js', +] + +EXTRA_JS_MODULES += [ + 'BackgroundPageThumbs.jsm', + 'PageThumbs.jsm', + 'PageThumbsWorker.js', + 'PageThumbUtils.jsm', +] + +JAR_MANIFESTS += ['jar.mn'] diff --git a/toolkit/components/thumbnails/test/.eslintrc.js b/toolkit/components/thumbnails/test/.eslintrc.js new file mode 100644 index 000000000..f6f8d62c2 --- /dev/null +++ b/toolkit/components/thumbnails/test/.eslintrc.js @@ -0,0 +1,8 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../testing/mochitest/browser.eslintrc.js", + "../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; diff --git a/toolkit/components/thumbnails/test/authenticate.sjs b/toolkit/components/thumbnails/test/authenticate.sjs new file mode 100644 index 000000000..58da655cf --- /dev/null +++ b/toolkit/components/thumbnails/test/authenticate.sjs @@ -0,0 +1,220 @@ +function handleRequest(request, response) +{ + try { + reallyHandleRequest(request, response); + } catch (e) { + response.setStatusLine("1.0", 200, "AlmostOK"); + response.write("Error handling request: " + e); + } +} + + +function reallyHandleRequest(request, response) { + var match; + var requestAuth = true, requestProxyAuth = true; + + // Allow the caller to drive how authentication is processed via the query. + // Eg, http://localhost:8888/authenticate.sjs?user=foo&realm=bar + // The extra ? allows the user/pass/realm checks to succeed if the name is + // at the beginning of the query string. + var query = "?" + request.queryString; + + var expected_user = "", expected_pass = "", realm = "mochitest"; + var proxy_expected_user = "", proxy_expected_pass = "", proxy_realm = "mochi-proxy"; + var huge = false, plugin = false, anonymous = false; + var authHeaderCount = 1; + // user=xxx + match = /[^_]user=([^&]*)/.exec(query); + if (match) + expected_user = match[1]; + + // pass=xxx + match = /[^_]pass=([^&]*)/.exec(query); + if (match) + expected_pass = match[1]; + + // realm=xxx + match = /[^_]realm=([^&]*)/.exec(query); + if (match) + realm = match[1]; + + // proxy_user=xxx + match = /proxy_user=([^&]*)/.exec(query); + if (match) + proxy_expected_user = match[1]; + + // proxy_pass=xxx + match = /proxy_pass=([^&]*)/.exec(query); + if (match) + proxy_expected_pass = match[1]; + + // proxy_realm=xxx + match = /proxy_realm=([^&]*)/.exec(query); + if (match) + proxy_realm = match[1]; + + // huge=1 + match = /huge=1/.exec(query); + if (match) + huge = true; + + // plugin=1 + match = /plugin=1/.exec(query); + if (match) + plugin = true; + + // multiple=1 + match = /multiple=([^&]*)/.exec(query); + if (match) + authHeaderCount = match[1]+0; + + // anonymous=1 + match = /anonymous=1/.exec(query); + if (match) + anonymous = true; + + // Look for an authentication header, if any, in the request. + // + // EG: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + // + // This test only supports Basic auth. The value sent by the client is + // "username:password", obscured with base64 encoding. + + var actual_user = "", actual_pass = "", authHeader, authPresent = false; + if (request.hasHeader("Authorization")) { + authPresent = true; + authHeader = request.getHeader("Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) + throw "Couldn't parse auth header: " + authHeader; + + var userpass = base64ToString(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) + throw "Couldn't decode auth header: " + userpass; + actual_user = match[1]; + actual_pass = match[2]; + } + + var proxy_actual_user = "", proxy_actual_pass = ""; + if (request.hasHeader("Proxy-Authorization")) { + authHeader = request.getHeader("Proxy-Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) + throw "Couldn't parse auth header: " + authHeader; + + var userpass = base64ToString(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) + throw "Couldn't decode auth header: " + userpass; + proxy_actual_user = match[1]; + proxy_actual_pass = match[2]; + } + + // Don't request authentication if the credentials we got were what we + // expected. + if (expected_user == actual_user && + expected_pass == actual_pass) { + requestAuth = false; + } + if (proxy_expected_user == proxy_actual_user && + proxy_expected_pass == proxy_actual_pass) { + requestProxyAuth = false; + } + + if (anonymous) { + if (authPresent) { + response.setStatusLine("1.0", 400, "Unexpected authorization header found"); + } else { + response.setStatusLine("1.0", 200, "Authorization header not found"); + } + } else { + if (requestProxyAuth) { + response.setStatusLine("1.0", 407, "Proxy authentication required"); + for (i = 0; i < authHeaderCount; ++i) + response.setHeader("Proxy-Authenticate", "basic realm=\"" + proxy_realm + "\"", true); + } else if (requestAuth) { + response.setStatusLine("1.0", 401, "Authentication required"); + for (i = 0; i < authHeaderCount; ++i) + response.setHeader("WWW-Authenticate", "basic realm=\"" + realm + "\"", true); + } else { + response.setStatusLine("1.0", 200, "OK"); + } + } + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write("<html xmlns='http://www.w3.org/1999/xhtml'>"); + response.write("<p>Login: <span id='ok'>" + (requestAuth ? "FAIL" : "PASS") + "</span></p>\n"); + response.write("<p>Proxy: <span id='proxy'>" + (requestProxyAuth ? "FAIL" : "PASS") + "</span></p>\n"); + response.write("<p>Auth: <span id='auth'>" + authHeader + "</span></p>\n"); + response.write("<p>User: <span id='user'>" + actual_user + "</span></p>\n"); + response.write("<p>Pass: <span id='pass'>" + actual_pass + "</span></p>\n"); + + if (huge) { + response.write("<div style='display: none'>"); + for (i = 0; i < 100000; i++) { + response.write("123456789\n"); + } + response.write("</div>"); + response.write("<span id='footnote'>This is a footnote after the huge content fill</span>"); + } + + if (plugin) { + response.write("<embed id='embedtest' style='width: 400px; height: 100px;' " + + "type='application/x-test'></embed>\n"); + } + + response.write("</html>"); +} + + +// base64 decoder +// +// Yoinked from extensions/xml-rpc/src/nsXmlRpcClient.js because btoa() +// doesn't seem to exist. :-( +/* Convert Base64 data to a string */ +const toBinaryTable = [ + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,62, -1,-1,-1,63, + 52,53,54,55, 56,57,58,59, 60,61,-1,-1, -1, 0,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14, + 15,16,17,18, 19,20,21,22, 23,24,25,-1, -1,-1,-1,-1, + -1,26,27,28, 29,30,31,32, 33,34,35,36, 37,38,39,40, + 41,42,43,44, 45,46,47,48, 49,50,51,-1, -1,-1,-1,-1 +]; +const base64Pad = '='; + +function base64ToString(data) { + + var result = ''; + var leftbits = 0; // number of bits decoded, but yet to be appended + var leftdata = 0; // bits decoded, but yet to be appended + + // Convert one by one. + for (var i = 0; i < data.length; i++) { + var c = toBinaryTable[data.charCodeAt(i) & 0x7f]; + var padding = (data[i] == base64Pad); + // Skip illegal characters and whitespace + if (c == -1) continue; + + // Collect data into leftdata, update bitcount + leftdata = (leftdata << 6) | c; + leftbits += 6; + + // If we have 8 or more bits, append 8 bits to the result + if (leftbits >= 8) { + leftbits -= 8; + // Append if not padding. + if (!padding) + result += String.fromCharCode((leftdata >> leftbits) & 0xff); + leftdata &= (1 << leftbits) - 1; + } + } + + // If there are any bits left, the base64 string was corrupted + if (leftbits) + throw Components.Exception('Corrupted base64 string'); + + return result; +} diff --git a/toolkit/components/thumbnails/test/background_red.html b/toolkit/components/thumbnails/test/background_red.html new file mode 100644 index 000000000..95159dd29 --- /dev/null +++ b/toolkit/components/thumbnails/test/background_red.html @@ -0,0 +1,3 @@ +<html> + <body bgcolor=ff0000></body> +</html> diff --git a/toolkit/components/thumbnails/test/background_red_redirect.sjs b/toolkit/components/thumbnails/test/background_red_redirect.sjs new file mode 100644 index 000000000..5f0852e19 --- /dev/null +++ b/toolkit/components/thumbnails/test/background_red_redirect.sjs @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(aRequest, aResponse) { + // Set HTTP Status. + aResponse.setStatusLine(aRequest.httpVersion, 301, "Moved Permanently"); + + // Set redirect URI. + aResponse.setHeader("Location", "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/background_red.html"); +} diff --git a/toolkit/components/thumbnails/test/background_red_scroll.html b/toolkit/components/thumbnails/test/background_red_scroll.html new file mode 100644 index 000000000..1e30bd3c6 --- /dev/null +++ b/toolkit/components/thumbnails/test/background_red_scroll.html @@ -0,0 +1,3 @@ +<html> + <body bgcolor=ff0000 style="overflow: scroll;"></body> +</html> diff --git a/toolkit/components/thumbnails/test/browser.ini b/toolkit/components/thumbnails/test/browser.ini new file mode 100644 index 000000000..3b87815ff --- /dev/null +++ b/toolkit/components/thumbnails/test/browser.ini @@ -0,0 +1,38 @@ +[DEFAULT] +support-files = + authenticate.sjs + background_red.html + background_red_redirect.sjs + background_red_scroll.html + head.js + privacy_cache_control.sjs + thumbnails_background.sjs + thumbnails_crash_content_helper.js + thumbnails_update.sjs + +[browser_thumbnails_bg_bad_url.js] +[browser_thumbnails_bg_crash_during_capture.js] +skip-if = !crashreporter +[browser_thumbnails_bg_crash_while_idle.js] +skip-if = !crashreporter +[browser_thumbnails_bg_basic.js] +[browser_thumbnails_bg_queueing.js] +[browser_thumbnails_bg_timeout.js] +[browser_thumbnails_bg_redirect.js] +[browser_thumbnails_bg_destroy_browser.js] +[browser_thumbnails_bg_no_cookies_sent.js] +[browser_thumbnails_bg_no_cookies_stored.js] +[browser_thumbnails_bg_no_auth_prompt.js] +[browser_thumbnails_bg_no_alert.js] +[browser_thumbnails_bg_no_duplicates.js] +[browser_thumbnails_bg_captureIfMissing.js] +[browser_thumbnails_bug726727.js] +[browser_thumbnails_bug727765.js] +[browser_thumbnails_bug818225.js] +[browser_thumbnails_capture.js] +[browser_thumbnails_expiration.js] +[browser_thumbnails_privacy.js] +[browser_thumbnails_redirect.js] +[browser_thumbnails_storage.js] +[browser_thumbnails_storage_migrate3.js] +[browser_thumbnails_update.js] diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_bad_url.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_bad_url.js new file mode 100644 index 000000000..df8ef8d96 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_bad_url.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + let url = "invalid-protocol://ffggfsdfsdf/"; + ok(!thumbnailExists(url), "Thumbnail should not be cached already."); + let numCalls = 0; + BackgroundPageThumbs.capture(url, { + onDone: function onDone(capturedURL) { + is(capturedURL, url, "Captured URL should be URL passed to capture"); + is(numCalls++, 0, "onDone should be called only once"); + ok(!thumbnailExists(url), + "Capture failed so thumbnail should not be cached"); + next(); + }, + }); + yield new Promise(resolve => { + bgAddPageThumbObserver(url).catch(function(err) { + ok(true, "page-thumbnail error produced"); + resolve(); + }); + }); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_basic.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_basic.js new file mode 100644 index 000000000..027e0bfb7 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_basic.js @@ -0,0 +1,20 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + let url = "http://www.example.com/"; + ok(!thumbnailExists(url), "Thumbnail should not be cached yet."); + + let capturePromise = new Promise(resolve => { + bgAddPageThumbObserver(url).then(() => { + ok(true, `page-thumbnail created for ${url}`); + resolve(); + }); + }); + let capturedURL = yield bgCapture(url); + is(capturedURL, url, "Captured URL should be URL passed to capture"); + yield capturePromise; + + ok(thumbnailExists(url), "Thumbnail should be cached after capture"); + removeThumbnail(url); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_captureIfMissing.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_captureIfMissing.js new file mode 100644 index 000000000..cd1f1c5c2 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_captureIfMissing.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + let numNotifications = 0; + function observe(subject, topic, data) { + is(topic, "page-thumbnail:create", "got expected topic"); + numNotifications += 1; + } + + Services.obs.addObserver(observe, "page-thumbnail:create", false); + + let url = "http://example.com/"; + let file = thumbnailFile(url); + ok(!file.exists(), "Thumbnail file should not already exist."); + + let capturedURL = yield bgCaptureIfMissing(url); + is(numNotifications, 1, "got notification of item being created."); + is(capturedURL, url, "Captured URL should be URL passed to capture"); + ok(file.exists(url), "Thumbnail should be cached after capture"); + + let past = Date.now() - 1000000000; + let pastFudge = past + 30000; + file.lastModifiedTime = past; + ok(file.lastModifiedTime < pastFudge, "Last modified time should stick!"); + capturedURL = yield bgCaptureIfMissing(url); + is(numNotifications, 1, "still only 1 notification of item being created."); + is(capturedURL, url, "Captured URL should be URL passed to second capture"); + ok(file.exists(), "Thumbnail should remain cached after second capture"); + ok(file.lastModifiedTime < pastFudge, + "File should not have been overwritten"); + + file.remove(false); + Services.obs.removeObserver(observe, "page-thumbnail:create"); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_during_capture.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_during_capture.js new file mode 100644 index 000000000..db67a04a8 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_during_capture.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + let crashObserver = bgAddCrashObserver(); + + // make a good capture first - this ensures we have the <browser> + let goodUrl = bgTestPageURL(); + yield bgCapture(goodUrl); + ok(thumbnailExists(goodUrl), "Thumbnail should be cached after capture"); + removeThumbnail(goodUrl); + + // inject our content script. + let mm = bgInjectCrashContentScript(); + + // queue up 2 captures - the first has a wait, so this is the one that + // will die. The second one should immediately capture after the crash. + let waitUrl = bgTestPageURL({ wait: 30000 }); + let sawWaitUrlCapture = false; + bgCapture(waitUrl, { onDone: () => { + sawWaitUrlCapture = true; + ok(!thumbnailExists(waitUrl), "Thumbnail should not have been saved due to the crash"); + }}); + bgCapture(goodUrl, { onDone: () => { + ok(sawWaitUrlCapture, "waitUrl capture should have finished first"); + ok(thumbnailExists(goodUrl), "We should have recovered and completed the 2nd capture after the crash"); + removeThumbnail(goodUrl); + // Test done. + ok(crashObserver.crashed, "Saw a crash from this test"); + next(); + }}); + let crashPromise = new Promise(resolve => { + bgAddPageThumbObserver(waitUrl).catch(function(err) { + ok(true, `page-thumbnail error thrown for ${waitUrl}`); + resolve(); + }); + }); + let capturePromise = new Promise(resolve => { + bgAddPageThumbObserver(goodUrl).then(() => { + ok(true, `page-thumbnail created for ${goodUrl}`); + resolve(); + }); + }); + + info("Crashing the thumbnail content process."); + mm.sendAsyncMessage("thumbnails-test:crash"); + yield crashPromise; + yield capturePromise; +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_while_idle.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_while_idle.js new file mode 100644 index 000000000..8ff6a3509 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_while_idle.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + let crashObserver = bgAddCrashObserver(); + + // make a good capture first - this ensures we have the <browser> + let goodUrl = bgTestPageURL(); + yield bgCapture(goodUrl); + ok(thumbnailExists(goodUrl), "Thumbnail should be cached after capture"); + removeThumbnail(goodUrl); + + // inject our content script. + let mm = bgInjectCrashContentScript(); + + // the observer for the crashing process is basically async, so it's hard + // to know when the <browser> has actually seen it. Easist is to just add + // our own observer. + Services.obs.addObserver(function onCrash() { + Services.obs.removeObserver(onCrash, "oop-frameloader-crashed"); + // spin the event loop to ensure the BPT observer was called. + executeSoon(function() { + // Now queue another capture and ensure it recovers. + bgCapture(goodUrl, { onDone: () => { + ok(thumbnailExists(goodUrl), "We should have recovered and handled new capture requests"); + removeThumbnail(goodUrl); + // Test done. + ok(crashObserver.crashed, "Saw a crash from this test"); + next(); + }}); + }); + }, "oop-frameloader-crashed", false); + + // Nothing is pending - crash the process. + info("Crashing the thumbnail content process."); + mm.sendAsyncMessage("thumbnails-test:crash"); + yield true; +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_destroy_browser.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_destroy_browser.js new file mode 100644 index 000000000..b83fdf583 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_destroy_browser.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + yield SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]] + }); + + let url1 = "http://example.com/1"; + ok(!thumbnailExists(url1), "First file should not exist yet."); + + let url2 = "http://example.com/2"; + ok(!thumbnailExists(url2), "Second file should not exist yet."); + + let defaultTimeout = BackgroundPageThumbs._destroyBrowserTimeout; + BackgroundPageThumbs._destroyBrowserTimeout = 1000; + + yield bgCapture(url1); + ok(thumbnailExists(url1), "First file should exist after capture."); + removeThumbnail(url1); + + yield wait(2000); + is(BackgroundPageThumbs._thumbBrowser, undefined, + "Thumb browser should be destroyed after timeout."); + BackgroundPageThumbs._destroyBrowserTimeout = defaultTimeout; + + yield bgCapture(url2); + ok(thumbnailExists(url2), "Second file should exist after capture."); + removeThumbnail(url2); + + isnot(BackgroundPageThumbs._thumbBrowser, undefined, + "Thumb browser should exist immediately after capture."); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_alert.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_alert.js new file mode 100644 index 000000000..5d6bd81f8 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_alert.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + let url = "data:text/html,<script>try { alert('yo!'); } catch (e) {}</script>"; + ok(!thumbnailExists(url), "Thumbnail file should not already exist."); + + let capturedURL = yield bgCapture(url); + is(capturedURL, url, "Captured URL should be URL passed to capture."); + ok(thumbnailExists(url), + "Thumbnail file should exist even though it alerted."); + removeThumbnail(url); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_auth_prompt.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_auth_prompt.js new file mode 100644 index 000000000..0eb9df7a9 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_auth_prompt.js @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// the following tests attempt to display modal dialogs. The test just +// relies on the fact that if the dialog was displayed the test will hang +// and timeout. IOW - the tests would pass if the dialogs appear and are +// manually closed by the user - so don't do that :) (obviously there is +// noone available to do that when run via tbpl etc, so this should be safe, +// and it's tricky to use the window-watcher to check a window *does not* +// appear - how long should the watcher be active before assuming it's not +// going to appear?) +function* runTests() { + let url = "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/authenticate.sjs?user=anyone"; + ok(!thumbnailExists(url), "Thumbnail file should not already exist."); + + let capturedURL = yield bgCapture(url); + is(capturedURL, url, "Captured URL should be URL passed to capture."); + ok(thumbnailExists(url), + "Thumbnail file should exist even though it requires auth."); + removeThumbnail(url); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_sent.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_sent.js new file mode 100644 index 000000000..afbedb382 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_sent.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + // Visit the test page in the browser and tell it to set a cookie. + let url = bgTestPageURL({ setGreenCookie: true }); + let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, url); + let browser = tab.linkedBrowser; + + // The root element of the page shouldn't be green yet. + yield ContentTask.spawn(browser, null, function () { + Assert.notEqual(content.document.documentElement.style.backgroundColor, + "rgb(0, 255, 0)", + "The page shouldn't be green yet."); + }); + + // Cookie should be set now. Reload the page to verify. Its root element + // will be green if the cookie's set. + browser.reload(); + yield BrowserTestUtils.browserLoaded(browser); + yield ContentTask.spawn(browser, null, function () { + Assert.equal(content.document.documentElement.style.backgroundColor, + "rgb(0, 255, 0)", + "The page should be green now."); + }); + + // Capture the page. Get the image data of the capture and verify it's not + // green. (Checking only the first pixel suffices.) + yield bgCapture(url); + ok(thumbnailExists(url), "Thumbnail file should exist after capture."); + + retrieveImageDataForURL(url, function ([r, g, b]) { + isnot([r, g, b].toString(), [0, 255, 0].toString(), + "The captured page should not be green."); + gBrowser.removeTab(tab); + removeThumbnail(url); + next(); + }); + yield true; +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_stored.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_stored.js new file mode 100644 index 000000000..90a1a890b --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_stored.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// check that if a page captured in the background attempts to set a cookie, +// that cookie is not saved for subsequent requests. +function* runTests() { + yield SpecialPowers.pushPrefEnv({ + set: [["privacy.usercontext.about_newtab_segregation.enabled", true]] + }); + let url = bgTestPageURL({ + setRedCookie: true, + iframe: bgTestPageURL({ setRedCookie: true}), + xhr: bgTestPageURL({ setRedCookie: true}) + }); + ok(!thumbnailExists(url), "Thumbnail file should not exist before capture."); + yield bgCapture(url); + ok(thumbnailExists(url), "Thumbnail file should exist after capture."); + removeThumbnail(url); + // now load it up in a browser - it should *not* be red, otherwise the + // cookie above was saved. + let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, url); + let browser = tab.linkedBrowser; + + // The root element of the page shouldn't be red. + yield ContentTask.spawn(browser, null, function() { + Assert.notEqual(content.document.documentElement.style.backgroundColor, + "rgb(255, 0, 0)", + "The page shouldn't be red."); + }); + + gBrowser.removeTab(tab); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_duplicates.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_duplicates.js new file mode 100644 index 000000000..31b504335 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_duplicates.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + let url = "http://example.com/1"; + ok(!thumbnailExists(url), "Thumbnail file should not already exist."); + let numCallbacks = 0; + let doneCallback = function(doneUrl) { + is(doneUrl, url, "called back with correct url"); + numCallbacks += 1; + // We will delete the file after the first callback, then check it + // still doesn't exist on the second callback, which should give us + // confidence that we didn't end up with 2 different captures happening + // for the same url... + if (numCallbacks == 1) { + ok(thumbnailExists(url), "Thumbnail file should now exist."); + removeThumbnail(url); + return; + } + if (numCallbacks == 2) { + ok(!thumbnailExists(url), "Thumbnail file should still be deleted."); + // and that's all we expect, so we are done... + next(); + return; + } + ok(false, "only expecting 2 callbacks"); + } + BackgroundPageThumbs.capture(url, {onDone: doneCallback}); + BackgroundPageThumbs.capture(url, {onDone: doneCallback}); + yield true; +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_queueing.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_queueing.js new file mode 100644 index 000000000..1426f6f4e --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_queueing.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + let urls = [ + "http://www.example.com/0", + "http://www.example.com/1", + // an item that will timeout to ensure timeouts work and we resume. + bgTestPageURL({ wait: 2002 }), + "http://www.example.com/2", + ]; + dontExpireThumbnailURLs(urls); + urls.forEach(url => { + ok(!thumbnailExists(url), "Thumbnail should not exist yet: " + url); + let isTimeoutTest = url.indexOf("wait") >= 0; + BackgroundPageThumbs.capture(url, { + timeout: isTimeoutTest ? 100 : 30000, + onDone: function onDone(capturedURL) { + ok(urls.length > 0, "onDone called, so URLs should still remain"); + is(capturedURL, urls.shift(), + "Captured URL should be currently expected URL (i.e., " + + "capture() callbacks should be called in the correct order)"); + if (isTimeoutTest) { + ok(!thumbnailExists(capturedURL), + "Thumbnail shouldn't exist for timed out capture"); + } else { + ok(thumbnailExists(capturedURL), + "Thumbnail should be cached after capture"); + removeThumbnail(url); + } + if (!urls.length) + // Test done. + next(); + }, + }); + }); + yield true; +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_redirect.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_redirect.js new file mode 100644 index 000000000..baa1b6d68 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_redirect.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + let finalURL = "http://example.com/redirected"; + let originalURL = bgTestPageURL({ redirect: finalURL }); + + ok(!thumbnailExists(originalURL), + "Thumbnail file for original URL should not exist yet."); + ok(!thumbnailExists(finalURL), + "Thumbnail file for final URL should not exist yet."); + + let captureOriginalPromise = new Promise(resolve => { + bgAddPageThumbObserver(originalURL).then(() => { + ok(true, `page-thumbnail created for ${originalURL}`); + resolve(); + }); + }); + + let captureFinalPromise = new Promise(resolve => { + bgAddPageThumbObserver(finalURL).then(() => { + ok(true, `page-thumbnail created for ${finalURL}`); + resolve(); + }); + }); + + let capturedURL = yield bgCapture(originalURL); + is(capturedURL, originalURL, + "Captured URL should be URL passed to capture"); + yield captureOriginalPromise; + yield captureFinalPromise; + ok(thumbnailExists(originalURL), + "Thumbnail for original URL should be cached"); + ok(thumbnailExists(finalURL), + "Thumbnail for final URL should be cached"); + + removeThumbnail(originalURL); + removeThumbnail(finalURL); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_timeout.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_timeout.js new file mode 100644 index 000000000..da05b4355 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_timeout.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function* runTests() { + let url = bgTestPageURL({ wait: 30000 }); + ok(!thumbnailExists(url), "Thumbnail should not be cached already."); + let numCalls = 0; + BackgroundPageThumbs.capture(url, { + timeout: 0, + onDone: function onDone(capturedURL) { + is(capturedURL, url, "Captured URL should be URL passed to capture"); + is(numCalls++, 0, "onDone should be called only once"); + ok(!thumbnailExists(url), + "Capture timed out so thumbnail should not be cached"); + next(); + }, + }); + yield new Promise(resolve => { + bgAddPageThumbObserver(url).catch(function(err) { + ok(true, `page-thumbnail error thrown for ${url}`); + resolve(); + }); + }); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bug726727.js b/toolkit/components/thumbnails/test/browser_thumbnails_bug726727.js new file mode 100644 index 000000000..f7f1f3deb --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bug726727.js @@ -0,0 +1,19 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests ensure that capturing a sites's thumbnail, saving it and + * retrieving it from the cache works. + */ +function* runTests() { + // Create a tab that shows an error page. + let tab = gBrowser.addTab("http://127.0.0.1:1/"); + let browser = tab.linkedBrowser; + yield browser.addEventListener("DOMContentLoaded", function onLoad() { + browser.removeEventListener("DOMContentLoaded", onLoad, false); + PageThumbs.shouldStoreThumbnail(browser, (aResult) => { + ok(!aResult, "we're not going to capture an error page"); + executeSoon(next); + }); + }, false); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bug727765.js b/toolkit/components/thumbnails/test/browser_thumbnails_bug727765.js new file mode 100644 index 000000000..c4faac685 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bug727765.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL = "http://mochi.test:8888/browser/toolkit/components/thumbnails/" + + "test/background_red_scroll.html"; + +function isRedThumbnailFuzz(r, g, b, expectedR, expectedB, expectedG, aFuzz) +{ + return (Math.abs(r - expectedR) <= aFuzz) && + (Math.abs(g - expectedG) <= aFuzz) && + (Math.abs(b - expectedB) <= aFuzz); +} + +// Test for black borders caused by scrollbars. +function* runTests() { + // Create a tab with a page with a red background and scrollbars. + yield addTab(URL); + yield captureAndCheckColor(255, 0, 0, "we have a red thumbnail"); + + // Check the thumbnail color of the bottom right pixel. + yield whenFileExists(URL); + yield retrieveImageDataForURL(URL, function (aData) { + let [r, g, b] = [].slice.call(aData, -4); + let fuzz = 2; // Windows 8 x64 blends with the scrollbar a bit. + var message = "Expected red thumbnail rgb(255, 0, 0), got " + r + "," + g + "," + b; + ok(isRedThumbnailFuzz(r, g, b, 255, 0, 0, fuzz), message); + next(); + }); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bug818225.js b/toolkit/components/thumbnails/test/browser_thumbnails_bug818225.js new file mode 100644 index 000000000..a7e1caa04 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bug818225.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL = "http://mochi.test:8888/browser/toolkit/components/thumbnails/" + + "test/background_red.html?" + Date.now(); + +// Test PageThumbs API function getThumbnailPath +function* runTests() { + + let path = PageThumbs.getThumbnailPath(URL); + yield testIfExists(path, false, "Thumbnail file does not exist"); + + yield addVisitsAndRepopulateNewTabLinks(URL, next); + yield createThumbnail(URL); + + path = PageThumbs.getThumbnailPath(URL); + let expectedPath = PageThumbsStorage.getFilePathForURL(URL); + is(path, expectedPath, "Thumbnail file has correct path"); + + yield testIfExists(path, true, "Thumbnail file exists"); + +} + +function createThumbnail(aURL) { + addTab(aURL, function () { + whenFileExists(aURL, function () { + gBrowser.removeTab(gBrowser.selectedTab); + next(); + }); + }); +} + +function testIfExists(aPath, aExpected, aMessage) { + return OS.File.exists(aPath).then( + function onSuccess(exists) { + is(exists, aExpected, aMessage); + }, + function onFailure(error) { + ok(false, "OS.File.exists() failed " + error); + } + ); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_capture.js b/toolkit/components/thumbnails/test/browser_thumbnails_capture.js new file mode 100644 index 000000000..47d94d31b --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_capture.js @@ -0,0 +1,20 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests ensure that capturing a sites's thumbnail, saving it and + * retrieving it from the cache works. + */ +function* runTests() { + // Create a tab with a red background. + yield addTab("data:text/html,<body bgcolor=ff0000></body>"); + yield captureAndCheckColor(255, 0, 0, "we have a red thumbnail"); + + // Load a page with a green background. + yield navigateTo("data:text/html,<body bgcolor=00ff00></body>"); + yield captureAndCheckColor(0, 255, 0, "we have a green thumbnail"); + + // Load a page with a blue background. + yield navigateTo("data:text/html,<body bgcolor=0000ff></body>"); + yield captureAndCheckColor(0, 0, 255, "we have a blue thumbnail"); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_expiration.js b/toolkit/components/thumbnails/test/browser_thumbnails_expiration.js new file mode 100644 index 000000000..4c73e17be --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_expiration.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL = "http://mochi.test:8888/?t=" + Date.now(); +const URL1 = URL + "#1"; +const URL2 = URL + "#2"; +const URL3 = URL + "#3"; + +var tmp = {}; +Cc["@mozilla.org/moz/jssubscript-loader;1"] + .getService(Ci.mozIJSSubScriptLoader) + .loadSubScript("resource://gre/modules/PageThumbs.jsm", tmp); + +const EXPIRATION_MIN_CHUNK_SIZE = 50; +const {PageThumbsExpiration} = tmp; + +function* runTests() { + // Create dummy URLs. + let dummyURLs = []; + for (let i = 0; i < EXPIRATION_MIN_CHUNK_SIZE + 10; i++) { + dummyURLs.push(URL + "#dummy" + i); + } + + // Make sure our thumbnails aren't expired too early. + dontExpireThumbnailURLs([URL1, URL2, URL3].concat(dummyURLs)); + + // Create three thumbnails. + yield createDummyThumbnail(URL1); + ok(thumbnailExists(URL1), "first thumbnail created"); + + yield createDummyThumbnail(URL2); + ok(thumbnailExists(URL2), "second thumbnail created"); + + yield createDummyThumbnail(URL3); + ok(thumbnailExists(URL3), "third thumbnail created"); + + // Remove the third thumbnail. + yield expireThumbnails([URL1, URL2]); + ok(thumbnailExists(URL1), "first thumbnail still exists"); + ok(thumbnailExists(URL2), "second thumbnail still exists"); + ok(!thumbnailExists(URL3), "third thumbnail has been removed"); + + // Remove the second thumbnail. + yield expireThumbnails([URL1]); + ok(thumbnailExists(URL1), "first thumbnail still exists"); + ok(!thumbnailExists(URL2), "second thumbnail has been removed"); + + // Remove all thumbnails. + yield expireThumbnails([]); + ok(!thumbnailExists(URL1), "all thumbnails have been removed"); + + // Create some more files than the min chunk size. + for (let url of dummyURLs) { + yield createDummyThumbnail(url); + } + + ok(dummyURLs.every(thumbnailExists), "all dummy thumbnails created"); + + // Expire thumbnails and expect 10 remaining. + yield expireThumbnails([]); + let remainingURLs = dummyURLs.filter(thumbnailExists); + is(remainingURLs.length, 10, "10 dummy thumbnails remaining"); + + // Expire thumbnails again. All should be gone by now. + yield expireThumbnails([]); + remainingURLs = remainingURLs.filter(thumbnailExists); + is(remainingURLs.length, 0, "no dummy thumbnails remaining"); +} + +function createDummyThumbnail(aURL) { + info("Creating dummy thumbnail for " + aURL); + let dummy = new Uint8Array(10); + for (let i = 0; i < 10; ++i) { + dummy[i] = i; + } + PageThumbsStorage.writeData(aURL, dummy).then( + function onSuccess() { + info("createDummyThumbnail succeeded"); + executeSoon(next); + }, + function onFailure(error) { + ok(false, "createDummyThumbnail failed " + error); + } + ); +} + +function expireThumbnails(aKeep) { + PageThumbsExpiration.expireThumbnails(aKeep).then( + function onSuccess() { + info("expireThumbnails succeeded"); + executeSoon(next); + }, + function onFailure(error) { + ok(false, "expireThumbnails failed " + error); + } + ); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_privacy.js b/toolkit/components/thumbnails/test/browser_thumbnails_privacy.js new file mode 100644 index 000000000..e7dc7b4d5 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_privacy.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const PREF_DISK_CACHE_SSL = "browser.cache.disk_cache_ssl"; +const URL = "://example.com/browser/toolkit/components/thumbnails/" + + "test/privacy_cache_control.sjs"; + +function* runTests() { + registerCleanupFunction(function () { + Services.prefs.clearUserPref(PREF_DISK_CACHE_SSL); + }); + + let positive = [ + // A normal HTTP page without any Cache-Control header. + {scheme: "http", cacheControl: null, diskCacheSSL: false}, + + // A normal HTTP page with 'Cache-Control: private'. + {scheme: "http", cacheControl: "private", diskCacheSSL: false}, + + // Capture HTTPS pages if browser.cache.disk_cache_ssl == true. + {scheme: "https", cacheControl: null, diskCacheSSL: true}, + {scheme: "https", cacheControl: "public", diskCacheSSL: true}, + {scheme: "https", cacheControl: "private", diskCacheSSL: true} + ]; + + let negative = [ + // Never capture pages with 'Cache-Control: no-store'. + {scheme: "http", cacheControl: "no-store", diskCacheSSL: false}, + {scheme: "http", cacheControl: "no-store", diskCacheSSL: true}, + {scheme: "https", cacheControl: "no-store", diskCacheSSL: false}, + {scheme: "https", cacheControl: "no-store", diskCacheSSL: true}, + + // Don't capture HTTPS pages by default. + {scheme: "https", cacheControl: null, diskCacheSSL: false}, + {scheme: "https", cacheControl: "public", diskCacheSSL: false}, + {scheme: "https", cacheControl: "private", diskCacheSSL: false} + ]; + + yield checkCombinations(positive, true); + yield checkCombinations(negative, false); +} + +function checkCombinations(aCombinations, aResult) { + let combi = aCombinations.shift(); + if (!combi) { + next(); + return; + } + + let url = combi.scheme + URL; + if (combi.cacheControl) + url += "?" + combi.cacheControl; + Services.prefs.setBoolPref(PREF_DISK_CACHE_SSL, combi.diskCacheSSL); + + // Add the test page as a top link so it has a chance to be thumbnailed + addVisitsAndRepopulateNewTabLinks(url, _ => { + testCombination(combi, url, aCombinations, aResult); + }); +} + +function testCombination(combi, url, aCombinations, aResult) { + let tab = gBrowser.selectedTab = gBrowser.addTab(url); + let browser = gBrowser.selectedBrowser; + + whenLoaded(browser, () => { + let msg = JSON.stringify(combi) + " == " + aResult; + PageThumbs.shouldStoreThumbnail(browser, (aIsSafeSite) => { + is(aIsSafeSite, aResult, msg); + gBrowser.removeTab(tab); + // Continue with the next combination. + checkCombinations(aCombinations, aResult); + }); + }); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_redirect.js b/toolkit/components/thumbnails/test/browser_thumbnails_redirect.js new file mode 100644 index 000000000..482dbc803 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_redirect.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL = "http://mochi.test:8888/browser/toolkit/components/thumbnails/" + + "test/background_red_redirect.sjs"; +// loading URL will redirect us to... +const FINAL_URL = "http://mochi.test:8888/browser/toolkit/components/" + + "thumbnails/test/background_red.html"; + +/** + * These tests ensure that we save and provide thumbnails for redirecting sites. + */ +function* runTests() { + dontExpireThumbnailURLs([URL, FINAL_URL]); + + // Kick off history by loading a tab first or the test fails in single mode. + yield addTab(URL); + gBrowser.removeTab(gBrowser.selectedTab); + + // Create a tab, redirecting to a page with a red background. + yield addTab(URL); + yield captureAndCheckColor(255, 0, 0, "we have a red thumbnail"); + + // Wait until the referrer's thumbnail's file has been written. + yield whenFileExists(URL); + yield retrieveImageDataForURL(URL, function ([r, g, b]) { + is("" + [r, g, b], "255,0,0", "referrer has a red thumbnail"); + next(); + }); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_storage.js b/toolkit/components/thumbnails/test/browser_thumbnails_storage.js new file mode 100644 index 000000000..972f956e5 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_storage.js @@ -0,0 +1,112 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL = "http://mochi.test:8888/"; +const URL_COPY = URL + "#copy"; + +XPCOMUtils.defineLazyGetter(this, "Sanitizer", function () { + let tmp = {}; + Cc["@mozilla.org/moz/jssubscript-loader;1"] + .getService(Ci.mozIJSSubScriptLoader) + .loadSubScript("chrome://browser/content/sanitize.js", tmp); + return tmp.Sanitizer; +}); + +/** + * These tests ensure that the thumbnail storage is working as intended. + * Newly captured thumbnails should be saved as files and they should as well + * be removed when the user sanitizes their history. + */ +function* runTests() { + yield Task.spawn(function*() { + dontExpireThumbnailURLs([URL, URL_COPY]); + + yield promiseClearHistory(); + yield promiseAddVisitsAndRepopulateNewTabLinks(URL); + yield promiseCreateThumbnail(); + + // Make sure Storage.copy() updates an existing file. + yield PageThumbsStorage.copy(URL, URL_COPY); + let copy = new FileUtils.File(PageThumbsStorage.getFilePathForURL(URL_COPY)); + let mtime = copy.lastModifiedTime -= 60; + + yield PageThumbsStorage.copy(URL, URL_COPY); + isnot(new FileUtils.File(PageThumbsStorage.getFilePathForURL(URL_COPY)).lastModifiedTime, mtime, + "thumbnail file was updated"); + + let file = new FileUtils.File(PageThumbsStorage.getFilePathForURL(URL)); + let fileCopy = new FileUtils.File(PageThumbsStorage.getFilePathForURL(URL_COPY)); + + // Clear the browser history. Retry until the files are gone because Windows + // locks them sometimes. + info("Clearing history"); + while (file.exists() || fileCopy.exists()) { + yield promiseClearHistory(); + } + info("History is clear"); + + info("Repopulating"); + yield promiseAddVisitsAndRepopulateNewTabLinks(URL); + yield promiseCreateThumbnail(); + + info("Clearing the last 10 minutes of browsing history"); + // Clear the last 10 minutes of browsing history. + yield promiseClearHistory(true); + + info("Attempt to clear file"); + // Retry until the file is gone because Windows locks it sometimes. + yield promiseClearFile(file, URL); + + info("Done"); + }); +} + +var promiseClearFile = Task.async(function*(aFile, aURL) { + if (!aFile.exists()) { + return undefined; + } + // Re-add our URL to the history so that history observer's onDeleteURI() + // is called again. + yield PlacesTestUtils.addVisits(makeURI(aURL)); + yield promiseClearHistory(true); + // Then retry. + return promiseClearFile(aFile, aURL); +}); + +function promiseClearHistory(aUseRange) { + let s = new Sanitizer(); + s.prefDomain = "privacy.cpd."; + + let prefs = gPrefService.getBranch(s.prefDomain); + prefs.setBoolPref("history", true); + prefs.setBoolPref("downloads", false); + prefs.setBoolPref("cache", false); + prefs.setBoolPref("cookies", false); + prefs.setBoolPref("formdata", false); + prefs.setBoolPref("offlineApps", false); + prefs.setBoolPref("passwords", false); + prefs.setBoolPref("sessions", false); + prefs.setBoolPref("siteSettings", false); + + if (aUseRange) { + let usec = Date.now() * 1000; + s.range = [usec - 10 * 60 * 1000 * 1000, usec]; + s.ignoreTimespan = false; + } + + return s.sanitize().then(() => { + s.range = null; + s.ignoreTimespan = true; + }); +} + +function promiseCreateThumbnail() { + return new Promise(resolve => { + addTab(URL, function () { + whenFileExists(URL, function () { + gBrowser.removeTab(gBrowser.selectedTab); + resolve(); + }); + }); + }); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_storage_migrate3.js b/toolkit/components/thumbnails/test/browser_thumbnails_storage_migrate3.js new file mode 100644 index 000000000..e7f150f87 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_storage_migrate3.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const URL = "http://mochi.test:8888/migration3"; +const URL2 = URL + "#2"; +const URL3 = URL + "#3"; +const THUMBNAIL_DIRECTORY = "thumbnails"; +const PREF_STORAGE_VERSION = "browser.pagethumbnails.storage_version"; + +var tmp = {}; +Cc["@mozilla.org/moz/jssubscript-loader;1"] + .getService(Ci.mozIJSSubScriptLoader) + .loadSubScript("resource://gre/modules/PageThumbs.jsm", tmp); +var {PageThumbsStorageMigrator} = tmp; + +XPCOMUtils.defineLazyServiceGetter(this, "gDirSvc", + "@mozilla.org/file/directory_service;1", "nsIProperties"); + +/** + * This test makes sure we correctly migrate to thumbnail storage version 3. + * This means copying existing thumbnails from the roaming to the local profile + * directory and should just apply to Linux. + */ +function* runTests() { + // Prepare a local profile directory. + let localProfile = FileUtils.getDir("ProfD", ["local-test"], true); + changeLocation("ProfLD", localProfile); + + let roaming = FileUtils.getDir("ProfD", [THUMBNAIL_DIRECTORY], true); + + // Set up some data in the roaming profile. + let name = PageThumbsStorage.getLeafNameForURL(URL); + let file = FileUtils.getFile("ProfD", [THUMBNAIL_DIRECTORY, name]); + writeDummyFile(file); + + name = PageThumbsStorage.getLeafNameForURL(URL2); + file = FileUtils.getFile("ProfD", [THUMBNAIL_DIRECTORY, name]); + writeDummyFile(file); + + name = PageThumbsStorage.getLeafNameForURL(URL3); + file = FileUtils.getFile("ProfD", [THUMBNAIL_DIRECTORY, name]); + writeDummyFile(file); + + // Pretend to have one of the thumbnails + // already in place at the new storage site. + name = PageThumbsStorage.getLeafNameForURL(URL3); + file = FileUtils.getFile("ProfLD", [THUMBNAIL_DIRECTORY, name]); + writeDummyFile(file, "no-overwrite-plz"); + + // Kick off thumbnail storage migration. + PageThumbsStorageMigrator.migrateToVersion3(localProfile.path); + ok(true, "migration finished"); + + // Wait until the first thumbnail was moved to its new location. + yield whenFileExists(URL); + ok(true, "first thumbnail moved"); + + // Wait for the second thumbnail to be moved as well. + yield whenFileExists(URL2); + ok(true, "second thumbnail moved"); + + yield whenFileRemoved(roaming); + ok(true, "roaming thumbnail directory removed"); + + // Check that our existing thumbnail wasn't overwritten. + is(getFileContents(file), "no-overwrite-plz", + "existing thumbnail was not overwritten"); + + // Sanity check: ensure that, until it is removed, deprecated + // function |getFileForURL| points to the same path as + // |getFilePathForURL|. + if ("getFileForURL" in PageThumbsStorage) { + file = PageThumbsStorage.getFileForURL(URL); + is(file.path, PageThumbsStorage.getFilePathForURL(URL), + "Deprecated getFileForURL and getFilePathForURL return the same path"); + } +} + +function changeLocation(aLocation, aNewDir) { + let oldDir = gDirSvc.get(aLocation, Ci.nsILocalFile); + gDirSvc.undefine(aLocation); + gDirSvc.set(aLocation, aNewDir); + + registerCleanupFunction(function () { + gDirSvc.undefine(aLocation); + gDirSvc.set(aLocation, oldDir); + }); +} + +function writeDummyFile(aFile, aContents) { + let fos = FileUtils.openSafeFileOutputStream(aFile); + let data = aContents || "dummy"; + fos.write(data, data.length); + FileUtils.closeSafeFileOutputStream(fos); +} + +function getFileContents(aFile) { + let istream = Cc["@mozilla.org/network/file-input-stream;1"] + .createInstance(Ci.nsIFileInputStream); + istream.init(aFile, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0); + return NetUtil.readInputStreamToString(istream, istream.available()); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_update.js b/toolkit/components/thumbnails/test/browser_thumbnails_update.js new file mode 100644 index 000000000..971a2994e --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_update.js @@ -0,0 +1,169 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests check the auto-update facility of the thumbnail service. + */ + +function* runTests() { + // A "trampoline" - a generator that iterates over sub-iterators + let tests = [ + simpleCaptureTest, + capIfStaleErrorResponseUpdateTest, + capIfStaleGoodResponseUpdateTest, + regularCapErrorResponseUpdateTest, + regularCapGoodResponseUpdateTest + ]; + for (let test of tests) { + info("Running subtest " + test.name); + for (let iterator of test()) + yield iterator; + } +} + +function ensureThumbnailStale(url) { + // We go behind the back of the thumbnail service and change the + // mtime of the file to be in the past. + let fname = PageThumbsStorage.getFilePathForURL(url); + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(fname); + ok(file.exists(), fname + " should exist"); + // Set it as very stale... + file.lastModifiedTime = Date.now() - 1000000000; +} + +function getThumbnailModifiedTime(url) { + let fname = PageThumbsStorage.getFilePathForURL(url); + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(fname); + return file.lastModifiedTime; +} + +// The tests! +/* Check functionality of a normal captureAndStoreIfStale request */ +function* simpleCaptureTest() { + let numNotifications = 0; + const URL = "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?simple"; + + function observe(subject, topic, data) { + is(topic, "page-thumbnail:create", "got expected topic"); + is(data, URL, "data is our test URL"); + if (++numNotifications == 2) { + // This is the final notification and signals test success... + Services.obs.removeObserver(observe, "page-thumbnail:create"); + gBrowser.removeTab(gBrowser.selectedTab); + next(); + } + } + + Services.obs.addObserver(observe, "page-thumbnail:create", false); + // Create a tab - we don't care what the content is. + yield addTab(URL); + let browser = gBrowser.selectedBrowser; + + // Capture the screenshot. + PageThumbs.captureAndStore(browser, function () { + // We've got a capture so should have seen the observer. + is(numNotifications, 1, "got notification of item being created."); + // The capture is now "fresh" - so requesting the URL should not cause + // a new capture. + PageThumbs.captureAndStoreIfStale(browser, function() { + is(numNotifications, 1, "still only 1 notification of item being created."); + + ensureThumbnailStale(URL); + // Ask for it to be updated. + PageThumbs.captureAndStoreIfStale(browser); + // But it's async, so wait - our observer above will call next() when + // the notification comes. + }); + }); + yield undefined // wait for callbacks to call 'next'... +} + +/* Check functionality of captureAndStoreIfStale when there is an error response + from the server. + */ +function* capIfStaleErrorResponseUpdateTest() { + const URL = "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?fail"; + yield addTab(URL); + + yield captureAndCheckColor(0, 255, 0, "we have a green thumbnail"); + // update the thumbnail to be stale, then re-request it. The server will + // return a 400 response and a red thumbnail. + // The service should not save the thumbnail - so we (a) check the thumbnail + // remains green and (b) check the mtime of the file is < now. + ensureThumbnailStale(URL); + yield navigateTo(URL); + // now() returns a higher-precision value than the modified time of a file. + // As we set the thumbnail very stale, allowing 1 second of "slop" here + // works around this while still keeping the test valid. + let now = Date.now() - 1000 ; + PageThumbs.captureAndStoreIfStale(gBrowser.selectedBrowser, () => { + ok(getThumbnailModifiedTime(URL) < now, "modified time should be < now"); + retrieveImageDataForURL(URL, function ([r, g, b]) { + is("" + [r, g, b], "" + [0, 255, 0], "thumbnail is still green"); + gBrowser.removeTab(gBrowser.selectedTab); + next(); + }); + }); + yield undefined; // wait for callback to call 'next'... +} + +/* Check functionality of captureAndStoreIfStale when there is a non-error + response from the server. This test is somewhat redundant - although it is + using a http:// URL instead of a data: url like most others. + */ +function* capIfStaleGoodResponseUpdateTest() { + const URL = "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?ok"; + yield addTab(URL); + let browser = gBrowser.selectedBrowser; + + yield captureAndCheckColor(0, 255, 0, "we have a green thumbnail"); + // update the thumbnail to be stale, then re-request it. The server will + // return a 200 response and a red thumbnail - so that new thumbnail should + // end up captured. + ensureThumbnailStale(URL); + yield navigateTo(URL); + // now() returns a higher-precision value than the modified time of a file. + // As we set the thumbnail very stale, allowing 1 second of "slop" here + // works around this while still keeping the test valid. + let now = Date.now() - 1000 ; + PageThumbs.captureAndStoreIfStale(browser, () => { + ok(getThumbnailModifiedTime(URL) >= now, "modified time should be >= now"); + // the captureAndStoreIfStale request saw a 200 response with the red body, + // so we expect to see the red version here. + retrieveImageDataForURL(URL, function ([r, g, b]) { + is("" + [r, g, b], "" + [255, 0, 0], "thumbnail is now red"); + next(); + }); + }); + yield undefined; // wait for callback to call 'next'... +} + +/* Check functionality of captureAndStore when there is an error response + from the server. + */ +function* regularCapErrorResponseUpdateTest() { + const URL = "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?fail"; + yield addTab(URL); + yield captureAndCheckColor(0, 255, 0, "we have a green thumbnail"); + gBrowser.removeTab(gBrowser.selectedTab); + // do it again - the server will return a 400, so the foreground service + // should not update it. + yield addTab(URL); + yield captureAndCheckColor(0, 255, 0, "we still have a green thumbnail"); +} + +/* Check functionality of captureAndStore when there is an OK response + from the server. + */ +function* regularCapGoodResponseUpdateTest() { + const URL = "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?ok"; + yield addTab(URL); + yield captureAndCheckColor(0, 255, 0, "we have a green thumbnail"); + gBrowser.removeTab(gBrowser.selectedTab); + // do it again - the server will return a 200, so the foreground service + // should update it. + yield addTab(URL); + yield captureAndCheckColor(255, 0, 0, "we now have a red thumbnail"); +} diff --git a/toolkit/components/thumbnails/test/head.js b/toolkit/components/thumbnails/test/head.js new file mode 100644 index 000000000..e8229508a --- /dev/null +++ b/toolkit/components/thumbnails/test/head.js @@ -0,0 +1,356 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var tmp = {}; +Cu.import("resource://gre/modules/PageThumbs.jsm", tmp); +Cu.import("resource://gre/modules/BackgroundPageThumbs.jsm", tmp); +Cu.import("resource://gre/modules/NewTabUtils.jsm", tmp); +Cu.import("resource:///modules/sessionstore/SessionStore.jsm", tmp); +Cu.import("resource://gre/modules/FileUtils.jsm", tmp); +Cu.import("resource://gre/modules/osfile.jsm", tmp); +var {PageThumbs, BackgroundPageThumbs, NewTabUtils, PageThumbsStorage, SessionStore, FileUtils, OS} = tmp; + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils", + "resource://testing-common/PlacesTestUtils.jsm"); + +var oldEnabledPref = Services.prefs.getBoolPref("browser.pagethumbnails.capturing_disabled"); +Services.prefs.setBoolPref("browser.pagethumbnails.capturing_disabled", false); + +registerCleanupFunction(function () { + while (gBrowser.tabs.length > 1) + gBrowser.removeTab(gBrowser.tabs[1]); + Services.prefs.setBoolPref("browser.pagethumbnails.capturing_disabled", oldEnabledPref) +}); + +/** + * Provide the default test function to start our test runner. + */ +function test() { + TestRunner.run(); +} + +/** + * The test runner that controls the execution flow of our tests. + */ +var TestRunner = { + /** + * Starts the test runner. + */ + run: function () { + waitForExplicitFinish(); + + SessionStore.promiseInitialized.then(function () { + this._iter = runTests(); + if (this._iter) { + this.next(); + } else { + finish(); + } + }.bind(this)); + }, + + /** + * Runs the next available test or finishes if there's no test left. + * @param aValue This value will be passed to the yielder via the runner's + * iterator. + */ + next: function (aValue) { + let obj = TestRunner._iter.next(aValue); + if (obj.done) { + finish(); + return; + } + + let value = obj.value || obj; + if (value && typeof value.then == "function") { + value.then(result => { + next(result); + }, error => { + ok(false, error + "\n" + error.stack); + }); + } + } +}; + +/** + * Continues the current test execution. + * @param aValue This value will be passed to the yielder via the runner's + * iterator. + */ +function next(aValue) { + TestRunner.next(aValue); +} + +/** + * Creates a new tab with the given URI. + * @param aURI The URI that's loaded in the tab. + * @param aCallback The function to call when the tab has loaded. + */ +function addTab(aURI, aCallback) { + let tab = gBrowser.selectedTab = gBrowser.addTab(aURI); + whenLoaded(tab.linkedBrowser, aCallback); +} + +/** + * Loads a new URI into the currently selected tab. + * @param aURI The URI to load. + */ +function navigateTo(aURI) { + let browser = gBrowser.selectedBrowser; + whenLoaded(browser); + browser.loadURI(aURI); +} + +/** + * Continues the current test execution when a load event for the given element + * has been received. + * @param aElement The DOM element to listen on. + * @param aCallback The function to call when the load event was dispatched. + */ +function whenLoaded(aElement, aCallback = next) { + aElement.addEventListener("load", function onLoad() { + aElement.removeEventListener("load", onLoad, true); + executeSoon(aCallback); + }, true); +} + +/** + * Captures a screenshot for the currently selected tab, stores it in the cache, + * retrieves it from the cache and compares pixel color values. + * @param aRed The red component's intensity. + * @param aGreen The green component's intensity. + * @param aBlue The blue component's intensity. + * @param aMessage The info message to print when comparing the pixel color. + */ +function captureAndCheckColor(aRed, aGreen, aBlue, aMessage) { + let browser = gBrowser.selectedBrowser; + // We'll get oranges if the expiration filter removes the file during the + // test. + dontExpireThumbnailURLs([browser.currentURI.spec]); + + // Capture the screenshot. + PageThumbs.captureAndStore(browser, function () { + retrieveImageDataForURL(browser.currentURI.spec, function ([r, g, b]) { + is("" + [r, g, b], "" + [aRed, aGreen, aBlue], aMessage); + next(); + }); + }); +} + +/** + * For a given URL, loads the corresponding thumbnail + * to a canvas and passes its image data to the callback. + * Note, not compat with e10s! + * @param aURL The url associated with the thumbnail. + * @param aCallback The function to pass the image data to. + */ +function retrieveImageDataForURL(aURL, aCallback) { + let width = 100, height = 100; + let thumb = PageThumbs.getThumbnailURL(aURL, width, height); + + let htmlns = "http://www.w3.org/1999/xhtml"; + let img = document.createElementNS(htmlns, "img"); + img.setAttribute("src", thumb); + + whenLoaded(img, function () { + let canvas = document.createElementNS(htmlns, "canvas"); + canvas.setAttribute("width", width); + canvas.setAttribute("height", height); + + // Draw the image to a canvas and compare the pixel color values. + let ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, width, height); + let result = ctx.getImageData(0, 0, 100, 100).data; + aCallback(result); + }); +} + +/** + * Returns the file of the thumbnail with the given URL. + * @param aURL The URL of the thumbnail. + */ +function thumbnailFile(aURL) { + return new FileUtils.File(PageThumbsStorage.getFilePathForURL(aURL)); +} + +/** + * Checks if a thumbnail for the given URL exists. + * @param aURL The url associated to the thumbnail. + */ +function thumbnailExists(aURL) { + let file = thumbnailFile(aURL); + return file.exists() && file.fileSize; +} + +/** + * Removes the thumbnail for the given URL. + * @param aURL The URL associated with the thumbnail. + */ +function removeThumbnail(aURL) { + let file = thumbnailFile(aURL); + file.remove(false); +} + +/** + * Calls addVisits, and then forces the newtab module to repopulate its links. + * See addVisits for parameter descriptions. + */ +function addVisitsAndRepopulateNewTabLinks(aPlaceInfo, aCallback) { + PlacesTestUtils.addVisits(makeURI(aPlaceInfo)).then(() => { + NewTabUtils.links.populateCache(aCallback, true); + }); +} +function promiseAddVisitsAndRepopulateNewTabLinks(aPlaceInfo) { + return new Promise(resolve => addVisitsAndRepopulateNewTabLinks(aPlaceInfo, resolve)); +} + +/** + * Calls a given callback when the thumbnail for a given URL has been found + * on disk. Keeps trying until the thumbnail has been created. + * + * @param aURL The URL of the thumbnail's page. + * @param [optional] aCallback + * Function to be invoked on completion. + */ +function whenFileExists(aURL, aCallback = next) { + let callback = aCallback; + if (!thumbnailExists(aURL)) { + callback = () => whenFileExists(aURL, aCallback); + } + + executeSoon(callback); +} + +/** + * Calls a given callback when the given file has been removed. + * Keeps trying until the file is removed. + * + * @param aFile The file that is being removed + * @param [optional] aCallback + * Function to be invoked on completion. + */ +function whenFileRemoved(aFile, aCallback) { + let callback = aCallback; + if (aFile.exists()) { + callback = () => whenFileRemoved(aFile, aCallback); + } + + executeSoon(callback || next); +} + +function wait(aMillis) { + setTimeout(next, aMillis); +} + +/** + * Makes sure that a given list of URLs is not implicitly expired. + * + * @param aURLs The list of URLs that should not be expired. + */ +function dontExpireThumbnailURLs(aURLs) { + let dontExpireURLs = (cb) => cb(aURLs); + PageThumbs.addExpirationFilter(dontExpireURLs); + + registerCleanupFunction(function () { + PageThumbs.removeExpirationFilter(dontExpireURLs); + }); +} + +function bgCapture(aURL, aOptions) { + bgCaptureWithMethod("capture", aURL, aOptions); +} + +function bgCaptureIfMissing(aURL, aOptions) { + bgCaptureWithMethod("captureIfMissing", aURL, aOptions); +} + +function bgCaptureWithMethod(aMethodName, aURL, aOptions = {}) { + // We'll get oranges if the expiration filter removes the file during the + // test. + dontExpireThumbnailURLs([aURL]); + if (!aOptions.onDone) + aOptions.onDone = next; + BackgroundPageThumbs[aMethodName](aURL, aOptions); +} + +function bgTestPageURL(aOpts = {}) { + let TEST_PAGE_URL = "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_background.sjs"; + return TEST_PAGE_URL + "?" + encodeURIComponent(JSON.stringify(aOpts)); +} + +function bgAddPageThumbObserver(url) { + return new Promise((resolve, reject) => { + function observe(subject, topic, data) { // jshint ignore:line + if (data === url) { + switch (topic) { + case "page-thumbnail:create": + resolve(); + break; + case "page-thumbnail:error": + reject(new Error("page-thumbnail:error")); + break; + } + Services.obs.removeObserver(observe, "page-thumbnail:create"); + Services.obs.removeObserver(observe, "page-thumbnail:error"); + } + } + Services.obs.addObserver(observe, "page-thumbnail:create", false); + Services.obs.addObserver(observe, "page-thumbnail:error", false); + }); +} + +function bgAddCrashObserver() { + let crashed = false; + Services.obs.addObserver(function crashObserver(subject, topic, data) { + is(topic, 'ipc:content-shutdown', 'Received correct observer topic.'); + ok(subject instanceof Components.interfaces.nsIPropertyBag2, + 'Subject implements nsIPropertyBag2.'); + // we might see this called as the process terminates due to previous tests. + // We are only looking for "abnormal" exits... + if (!subject.hasKey("abnormal")) { + info("This is a normal termination and isn't the one we are looking for..."); + return; + } + Services.obs.removeObserver(crashObserver, 'ipc:content-shutdown'); + crashed = true; + + var dumpID; + if ('nsICrashReporter' in Components.interfaces) { + dumpID = subject.getPropertyAsAString('dumpID'); + ok(dumpID, "dumpID is present and not an empty string"); + } + + if (dumpID) { + var minidumpDirectory = getMinidumpDirectory(); + removeFile(minidumpDirectory, dumpID + '.dmp'); + removeFile(minidumpDirectory, dumpID + '.extra'); + } + }, 'ipc:content-shutdown', false); + return { + get crashed() { + return crashed; + } + }; +} + +function bgInjectCrashContentScript() { + const TEST_CONTENT_HELPER = "chrome://mochitests/content/browser/toolkit/components/thumbnails/test/thumbnails_crash_content_helper.js"; + let thumbnailBrowser = BackgroundPageThumbs._thumbBrowser; + let mm = thumbnailBrowser.messageManager; + mm.loadFrameScript(TEST_CONTENT_HELPER, false); + return mm; +} + +function getMinidumpDirectory() { + var dir = Services.dirsvc.get('ProfD', Components.interfaces.nsIFile); + dir.append("minidumps"); + return dir; +} + +function removeFile(directory, filename) { + var file = directory.clone(); + file.append(filename); + if (file.exists()) { + file.remove(false); + } +} diff --git a/toolkit/components/thumbnails/test/privacy_cache_control.sjs b/toolkit/components/thumbnails/test/privacy_cache_control.sjs new file mode 100644 index 000000000..6c7c16edb --- /dev/null +++ b/toolkit/components/thumbnails/test/privacy_cache_control.sjs @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(aRequest, aResponse) { + // Set HTTP Status + aResponse.setStatusLine(aRequest.httpVersion, 200, "OK"); + + // Set Cache-Control header. + let value = aRequest.queryString; + if (value) + aResponse.setHeader("Cache-Control", value); + + // Set the response body. + aResponse.write("<!DOCTYPE html><html><body></body></html>"); +} diff --git a/toolkit/components/thumbnails/test/test_thumbnails_interfaces.js b/toolkit/components/thumbnails/test/test_thumbnails_interfaces.js new file mode 100644 index 000000000..8272b2e06 --- /dev/null +++ b/toolkit/components/thumbnails/test/test_thumbnails_interfaces.js @@ -0,0 +1,31 @@ +// tests to check that moz-page-thumb URLs correctly resolve as file:// URLS +"use strict"; + +const Cu = Components.utils; +const Cc = Components.classes; +const Cr = Components.results; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Services.jsm"); + +// need profile so that PageThumbsStorage can resolve the path to the underlying file +do_get_profile(); + +function run_test() { + // first check the protocol handler implements the correct interface + let handler = Services.io.getProtocolHandler("moz-page-thumb"); + ok(handler instanceof Ci.nsISubstitutingProtocolHandler, + "moz-page-thumb handler provides substituting interface"); + + // then check that the file URL resolution works + let uri = Services.io.newURI("moz-page-thumb://thumbnail/?url=http%3A%2F%2Fwww.mozilla.org%2F", + null, null); + ok(uri instanceof Ci.nsIFileURL, "moz-page-thumb:// is a FileURL"); + ok(uri.file, "This moz-page-thumb:// object is backed by a file"); + + // and check that the error case works as specified + let bad = Services.io.newURI("moz-page-thumb://wronghost/?url=http%3A%2F%2Fwww.mozilla.org%2F", + null, null); + Assert.throws(() => handler.resolveURI(bad), /NS_ERROR_NOT_AVAILABLE/i, + "moz-page-thumb object with wrong host must not resolve to a file path"); +} diff --git a/toolkit/components/thumbnails/test/thumbnails_background.sjs b/toolkit/components/thumbnails/test/thumbnails_background.sjs new file mode 100644 index 000000000..f1cce96a0 --- /dev/null +++ b/toolkit/components/thumbnails/test/thumbnails_background.sjs @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// The timer never fires if it's not declared and set to this variable outside +// handleRequest, as if it's getting GC'ed when handleRequest's scope goes away. +// Shouldn't the timer thread hold a strong reference to it? +var timer; + +function handleRequest(req, resp) { + resp.processAsync(); + resp.setHeader("Cache-Control", "no-cache, no-store", false); + resp.setHeader("Content-Type", "text/html;charset=utf-8", false); + + let opts = {}; + try { + opts = JSON.parse(decodeURIComponent(req.queryString)); + } + catch (err) {} + + let setCookieScript = ""; + if (opts.setRedCookie) { + resp.setHeader("Set-Cookie", "red", false); + setCookieScript = '<script>document.cookie="red";</script>'; + } + + if (opts.setGreenCookie) { + resp.setHeader("Set-Cookie", "green", false); + setCookieScript = '<script>document.cookie="green";</script>'; + } + + if (opts.iframe) { + setCookieScript += '<iframe src="' + opts.iframe + '" />'; + } + + if (opts.xhr) { + setCookieScript += ` + <script> + var req = new XMLHttpRequest(); + req.open("GET", "${opts.xhr}", true); + req.send(); + </script> + `; + } + + if (req.hasHeader("Cookie") && + req.getHeader("Cookie").split(";").indexOf("red") >= 0) { + resp.write('<html style="background: #f00;">' + setCookieScript + '</html>'); + resp.finish(); + return; + } + + if (req.hasHeader("Cookie") && + req.getHeader("Cookie").split(";").indexOf("green") >= 0) { + resp.write('<html style="background: #0f0;">' + setCookieScript + '</html>'); + resp.finish(); + return; + } + + if (opts.redirect) { + resp.setHeader("Location", opts.redirect); + resp.setStatusLine(null, 303, null); + resp.finish(); + return; + } + + if (opts.wait) { + resp.write("Waiting " + opts.wait + " ms... "); + timer = Components.classes["@mozilla.org/timer;1"]. + createInstance(Components.interfaces.nsITimer); + timer.init(function ding() { + resp.write("OK!"); + resp.finish(); + }, opts.wait, Components.interfaces.nsITimer.TYPE_ONE_SHOT); + return; + } + + resp.write("<pre>" + JSON.stringify(opts, undefined, 2) + "</pre>" + setCookieScript); + resp.finish(); +} diff --git a/toolkit/components/thumbnails/test/thumbnails_crash_content_helper.js b/toolkit/components/thumbnails/test/thumbnails_crash_content_helper.js new file mode 100644 index 000000000..935175f86 --- /dev/null +++ b/toolkit/components/thumbnails/test/thumbnails_crash_content_helper.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. +* http://creativecommons.org/publicdomain/zero/1.0/ */ + +var Cu = Components.utils; + +// Ideally we would use CrashTestUtils.jsm, but that's only available for +// xpcshell tests - so we just copy a ctypes crasher from it. +Cu.import("resource://gre/modules/ctypes.jsm"); +var crash = function() { // this will crash when called. + let zero = new ctypes.intptr_t(8); + let badptr = ctypes.cast(zero, ctypes.PointerType(ctypes.int32_t)); + badptr.contents +}; + + +var TestHelper = { + init: function() { + addMessageListener("thumbnails-test:crash", this); + }, + + receiveMessage: function(msg) { + switch (msg.name) { + case "thumbnails-test:crash": + privateNoteIntentionalCrash(); + crash(); + break; + } + }, +} + +TestHelper.init(); diff --git a/toolkit/components/thumbnails/test/thumbnails_update.sjs b/toolkit/components/thumbnails/test/thumbnails_update.sjs new file mode 100644 index 000000000..4d8ab406a --- /dev/null +++ b/toolkit/components/thumbnails/test/thumbnails_update.sjs @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This server-side script is used for browser_thumbnails_update. One of the
+// main things it must do in all cases is ensure a Cache-Control: no-store
+// header, so the foreground capture doesn't interfere with the testing.
+
+// If the querystring is "simple", then all it does it return some content -
+// it doesn't really matter what that content is.
+
+// Otherwise, its main role is that it must return different *content* for the
+// second request than it did for the first.
+// Also, it should be able to return an error response when requested for the
+// second response.
+// So the basic tests will be to grab the thumbnail, then request it to be
+// grabbed again:
+// * If the second request succeeded, the new thumbnail should exist.
+// * If the second request is an error, the new thumbnail should be ignored.
+
+function handleRequest(aRequest, aResponse) {
+ aResponse.setHeader("Content-Type", "text/html;charset=utf-8", false);
+ // we want to disable gBrowserThumbnails on-load capture for these responses,
+ // so set as a "no-store" response.
+ aResponse.setHeader("Cache-Control", "no-store");
+
+ // for the simple test - just return some content.
+ if (aRequest.queryString == "simple") {
+ aResponse.write("<html><body></body></html>");
+ aResponse.setStatusLine(aRequest.httpVersion, 200, "Its simply OK");
+ return;
+ }
+
+ // it's one of the more complex tests where the first request for the given
+ // URL must return different content than the second, and possibly an error
+ // response for the second
+ let doneError = getState(aRequest.queryString);
+ if (!doneError) {
+ // first request - return a response with a green body and 200 response.
+ aResponse.setStatusLine(aRequest.httpVersion, 200, "OK - It's green");
+ aResponse.write("<html><body bgcolor=00ff00></body></html>");
+ // set the state so the next request does the "second request" thing below.
+ setState(aRequest.queryString, "yep");
+ } else {
+ // second request - this will be done by the b/g service.
+ // We always return a red background, but depending on the query string we
+ // return either a 200 or 401 response.
+ if (aRequest.queryString == "fail")
+ aResponse.setStatusLine(aRequest.httpVersion, 401, "Oh no you don't");
+ else
+ aResponse.setStatusLine(aRequest.httpVersion, 200, "OK - It's red");
+ aResponse.write("<html><body bgcolor=ff0000></body></html>");
+ // reset the error state incase this ends up being reused for the
+ // same url and querystring.
+ setState(aRequest.queryString, "");
+ }
+}
diff --git a/toolkit/components/thumbnails/test/xpcshell.ini b/toolkit/components/thumbnails/test/xpcshell.ini new file mode 100644 index 000000000..4dae8cced --- /dev/null +++ b/toolkit/components/thumbnails/test/xpcshell.ini @@ -0,0 +1,6 @@ +[DEFAULT] +head = +tail = + +[test_thumbnails_interfaces.js] +skip-if = os == 'android' # xpcom interface not packaged |