diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/thumbnails | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/thumbnails')
53 files changed, 4658 insertions, 0 deletions
diff --git a/toolkit/components/thumbnails/BackgroundPageThumbs.sys.mjs b/toolkit/components/thumbnails/BackgroundPageThumbs.sys.mjs new file mode 100644 index 0000000000..c50d5ca1da --- /dev/null +++ b/toolkit/components/thumbnails/BackgroundPageThumbs.sys.mjs @@ -0,0 +1,788 @@ +/* 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 DEFAULT_CAPTURE_TIMEOUT = 30000; // ms +// For testing, the above timeout is excessive, and makes our tests overlong. +const TESTING_CAPTURE_TIMEOUT = 5000; // ms + +const DESTROY_BROWSER_TIMEOUT = 60000; // ms + +// Let the page settle for this amount of milliseconds before capturing to allow +// for any in-page changes or redirects. +const SETTLE_WAIT_TIME = 2500; +// For testing, the above timeout is excessive, and makes our tests overlong. +const TESTING_SETTLE_WAIT_TIME = 0; + +const TELEMETRY_HISTOGRAM_ID_PREFIX = "FX_THUMBNAILS_BG_"; + +const ABOUT_NEWTAB_SEGREGATION_PREF = + "privacy.usercontext.about_newtab_segregation.enabled"; + +import { + PageThumbs, + PageThumbsStorage, +} from "resource://gre/modules/PageThumbs.sys.mjs"; + +// 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; +const TEL_CAPTURE_DONE_LOAD_FAILED = 6; +const TEL_CAPTURE_DONE_IMAGE_ZERO_DIMENSION = 7; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", +}); + +export 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). + * @opt isImage If true, backgroundPageThumbsContent will attempt to render + * the url directly to canvas. Note that images will mostly get + * detected and rendered as such anyway, but this will ensure it. + * @opt targetWidth The target width when capturing an image. + * @opt backgroundColor The background colour when capturing an image. + * @opt dontStore If set to true, the image blob won't be stored to disk, an + * object will instead be passed as third argument to onDone: + * { + * data: an ArrayBuffer containing the data + * contentType: the data content-type + * originalUrl: the originally requested url + * currentUrl: the final url after redirects + * } + * @opt contentType can be set to an image contentType for the capture, + * defaults to PageThumbs.contentType. + */ + capture(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. + * unloadingPromise This option is resolved when the calling context is + * unloading, so things can be cleaned up to avoid leak. + * @return {Promise} A Promise that resolves when this task completes + */ + async captureIfMissing(url, options = {}) { + // Short circuit this function if pref is enabled, or else we leak observers. + // See Bug 1400562 + if (!PageThumbs._prefEnabled()) { + if (options.onDone) { + options.onDone(url); + } + return url; + } + // 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 = await PageThumbsStorage.fileExistsForURL(url); + if (exists) { + if (options.onDone) { + options.onDone(url); + } + return url; + } + let thumbPromise = new Promise((resolve, reject) => { + let observe = (subject, topic, data) => { + if (data === url) { + switch (topic) { + case "page-thumbnail:create": + resolve(); + break; + case "page-thumbnail:error": + reject(new Error("page-thumbnail:error")); + break; + } + cleanup(); + } + }; + Services.obs.addObserver(observe, "page-thumbnail:create"); + Services.obs.addObserver(observe, "page-thumbnail:error"); + + // Make sure to clean up to avoid leaks by removing observers when + // observed or when our caller is unloading + function cleanup() { + if (observe) { + Services.obs.removeObserver(observe, "page-thumbnail:create"); + Services.obs.removeObserver(observe, "page-thumbnail:error"); + observe = null; + } + } + if (options.unloadingPromise) { + options.unloadingPromise.then(cleanup); + } + }); + try { + this.capture(url, options); + await 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() { + this._renewThumbBrowser = true; + }, + + get useFissionBrowser() { + return Services.appinfo.fissionAutostart; + }, + + /** + * 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() { + if (this._parentWin) { + // Already fully initialized. + return true; + } + if (this._startedParentWinInit) { + // Already started initializing. + return false; + } + + this._startedParentWinInit = true; + + // Create a windowless browser and load our hosting + // (privileged) document in it. + const flags = this.useFissionBrowser + ? Ci.nsIWebBrowserChrome.CHROME_REMOTE_WINDOW | + Ci.nsIWebBrowserChrome.CHROME_FISSION_WINDOW + : 0; + let wlBrowser = Services.appShell.createWindowlessBrowser(true, flags); + wlBrowser.QueryInterface(Ci.nsIInterfaceRequestor); + let webProgress = wlBrowser.getInterface(Ci.nsIWebProgress); + this._listener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsIWebProgressListener2", + "nsISupportsWeakReference", + ]), + }; + this._listener.onStateChange = (wbp, request, stateFlags, status) => { + if (!request) { + return; + } + if ( + stateFlags & Ci.nsIWebProgressListener.STATE_STOP && + stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK + ) { + webProgress.removeProgressListener(this._listener); + delete this._listener; + // Get the window reference via the document. + this._parentWin = wlBrowser.document.defaultView; + this._processCaptureQueue(); + } + }; + webProgress.addProgressListener( + this._listener, + Ci.nsIWebProgress.NOTIFY_STATE_ALL + ); + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }; + wlBrowser.loadURI( + Services.io.newURI("chrome://global/content/backgroundPageThumbs.xhtml"), + loadURIOptions + ); + this._windowlessContainer = wlBrowser; + + return false; + }, + + _init() { + Services.prefs.addObserver(ABOUT_NEWTAB_SEGREGATION_PREF, this); + Services.obs.addObserver(this, "profile-before-change"); + }, + + observe(subject, topic, data) { + if (topic == "profile-before-change") { + this._destroy(); + } else if ( + topic == "nsPref:changed" && + data == ABOUT_NEWTAB_SEGREGATION_PREF + ) { + BackgroundPageThumbs.renewThumbnailBrowser(); + } + }, + + /** + * Destroys the service. Queued and pending captures will never complete, and + * their consumer callbacks will never be called. + */ + _destroy() { + if (this._captureQueue) { + this._captureQueue.forEach(cap => cap.destroy()); + } + this._destroyBrowser(); + if (this._windowlessContainer) { + this._windowlessContainer.close(); + } + delete this._captureQueue; + delete this._windowlessContainer; + delete this._startedParentWinInit; + delete this._parentWin; + delete this._listener; + }, + + QueryInterface: ChromeUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsIWebProgressListener2, + Ci.nsISupportsWeakReference, + ]), + + onStateChange(wbp, request, stateFlags, status) { + if (!request || !wbp.isTopLevel) { + return; + } + + if ( + stateFlags & Ci.nsIWebProgressListener.STATE_STOP && + stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK + ) { + // If about:blank is being loaded after a capture, move on + // to the next capture, otherwise ignore about:blank loads. + if ( + request instanceof Ci.nsIChannel && + request.URI.spec == "about:blank" + ) { + if (this._expectingAboutBlank) { + this._expectingAboutBlank = false; + if (this._captureQueue.length) { + this._processCaptureQueue(); + } + } + return; + } + + if (!this._captureQueue.length) { + return; + } + + let currentCapture = this._captureQueue[0]; + if ( + Components.isSuccessCode(status) || + status === Cr.NS_BINDING_ABORTED + ) { + this._thumbBrowser.ownerGlobal.requestIdleCallback(() => { + currentCapture.pageLoaded(this._thumbBrowser); + }); + } else { + currentCapture._done( + this._thumbBrowser, + null, + currentCapture.timedOut + ? TEL_CAPTURE_DONE_TIMEOUT + : TEL_CAPTURE_DONE_LOAD_FAILED + ); + } + } + }, + + /** + * Creates the thumbnail browser if it doesn't already exist. + */ + _ensureBrowser() { + if (this._thumbBrowser && !this._renewThumbBrowser) { + return; + } + + this._destroyBrowser(); + this._renewThumbBrowser = false; + + let browser = this._parentWin.document.createXULElement("browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("remote", "true"); + if (this.useFissionBrowser) { + browser.setAttribute("maychangeremoteness", "true"); + } + browser.setAttribute("disableglobalhistory", "true"); + browser.setAttribute("messagemanagergroup", "thumbnails"); + + if (Services.prefs.getBoolPref(ABOUT_NEWTAB_SEGREGATION_PREF)) { + // Use the private container for thumbnails. + let privateIdentity = lazy.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"; + browser.style.colorScheme = "env(-moz-content-preferred-color-scheme)"; + + this._parentWin.document.documentElement.appendChild(browser); + + browser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW); + browser.mute(); + + // 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", event => { + if (!event.isTopFrame) { + // It was a subframe that crashed. We'll ignore this. + return; + } + + console.error("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) { + // 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.dispatchToMainThread(() => { + curCapture._done(browser, null, TEL_CAPTURE_DONE_CRASHED); + }); + } + // 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. + }); + + this._thumbBrowser = browser; + browser.docShellIsActive = false; + }, + + _destroyBrowser() { + if (!this._thumbBrowser) { + return; + } + this._expectingAboutBlank = false; + this._thumbBrowser.remove(); + delete this._thumbBrowser; + }, + + async _loadAboutBlank() { + if (this._expectingAboutBlank) { + return; + } + + this._expectingAboutBlank = true; + + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_STOP_CONTENT, + }; + this._thumbBrowser.loadURI( + Services.io.newURI("about:blank"), + loadURIOptions + ); + }, + + /** + * Starts the next capture if the queue is not empty and the service is fully + * initialized. + */ + _processCaptureQueue() { + if (!this._captureQueue.length) { + if (this._thumbBrowser) { + BackgroundPageThumbs._loadAboutBlank(); + } + return; + } + + if ( + this._captureQueue[0].pending || + !this._ensureParentWindowReady() || + this._expectingAboutBlank + ) { + return; + } + + // Ready to start the first capture in the queue. + this._ensureBrowser(); + this._captureQueue[0].start(this._thumbBrowser); + if (this._destroyBrowserTimer) { + this._destroyBrowserTimer.cancel(); + delete this._destroyBrowserTimer; + } + }, + + /** + * Called when the current capture completes or fails (eg, times out, remote + * process crashes.) + */ + _onCaptureOrTimeout(capture, reason) { + // 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 (reason != 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, +}; + +BackgroundPageThumbs._init(); + +/** + * 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.redirectTimer = null; + this.timedOut = false; + this.options = options; + this.id = Capture.nextID++; + this.creationDate = new Date(); + this.doneCallbacks = []; + if (options.onDone) { + this.doneCallbacks.push(options.onDone); + } +} + +Capture.prototype = { + get pending() { + return !!this._timeoutTimer; + }, + + /** + * Sends a message to the content script to start the capture. + * + * @param browser The thumbnail browser. + */ + start(browser) { + this.startDate = new Date(); + tel("CAPTURE_QUEUE_TIME_MS", this.startDate - this.creationDate); + + let fallbackTimeout = Cu.isInAutomation + ? TESTING_CAPTURE_TIMEOUT + : DEFAULT_CAPTURE_TIMEOUT; + + // timeout timer + let timeout = + typeof this.options.timeout == "number" + ? this.options.timeout + : fallbackTimeout; + this._timeoutTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this._timeoutTimer.initWithCallback( + this, + timeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + + this._browser = browser; + + if (!browser.browsingContext) { + return; + } + + this._pageLoadStartTime = new Date(); + + BackgroundPageThumbs._expectingAboutBlank = false; + + let thumbnailsActor = browser.browsingContext.currentWindowGlobal.getActor( + "BackgroundThumbnails" + ); + thumbnailsActor + .sendQuery("Browser:Thumbnail:LoadURL", { + url: this.url, + }) + .then( + success => { + // If it failed, then this was likely a bad url. If successful, + // BackgroundPageThumbs.onStateChange will call _done() after the + // load has completed. + if (!success) { + this._done(browser, null, TEL_CAPTURE_DONE_BAD_URI); + } + }, + failure => { + // The query can fail when a crash occurs while loading. The error causes + // thumbnail crash tests to fail with an uninteresting error message. + } + ); + }, + + readBlob: function readBlob(blob) { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.onloadend = function onloadend() { + if (reader.readyState != FileReader.DONE) { + reject(reader.error); + } else { + resolve(reader.result); + } + }; + reader.readAsArrayBuffer(blob); + }); + }, + + async pageLoaded(aBrowser) { + if (this.timedOut) { + this._done(aBrowser, null, TEL_CAPTURE_DONE_TIMEOUT); + return; + } + + let waitTime = Cu.isInAutomation + ? TESTING_SETTLE_WAIT_TIME + : SETTLE_WAIT_TIME; + + // There was additional activity, so restart the wait timer + if (this.redirectTimer) { + this.redirectTimer.delay = waitTime; + return; + } + + // The requested page has loaded or stopped/aborted, so capture the page + // soon but first let it settle in case of in-page redirects + await new Promise(resolve => { + this.redirectTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + this.redirectTimer.init(resolve, waitTime, Ci.nsITimer.TYPE_ONE_SHOT); + }); + + this.redirectTimer = null; + + let pageLoadTime = new Date() - this._pageLoadStartTime; + let canvasDrawStartTime = new Date(); + + let canvas = PageThumbs.createCanvas(aBrowser.ownerGlobal, 1, 1); + try { + await PageThumbs.captureToCanvas( + aBrowser, + canvas, + { + isBackgroundThumb: true, + isImage: this.options.isImage, + backgroundColor: this.options.backgroundColor, + }, + true + ); + } catch (ex) { + this._done( + aBrowser, + null, + ex == "IMAGE_ZERO_DIMENSION" + ? TEL_CAPTURE_DONE_IMAGE_ZERO_DIMENSION + : TEL_CAPTURE_DONE_LOAD_FAILED + ); + return; + } + + let canvasDrawTime = new Date() - canvasDrawStartTime; + + let contentType = + (this.options.dontStore && this.options.contentType) || + PageThumbs.contentType; + let imageData = await new Promise(resolve => { + canvas.toBlob(blob => { + resolve(blob, contentType); + }, contentType); + }); + + this._done(aBrowser, imageData, TEL_CAPTURE_DONE_OK, { + CAPTURE_PAGE_LOAD_TIME_MS: pageLoadTime, + CAPTURE_CANVAS_DRAW_TIME_MS: canvasDrawTime, + }); + }, + + /** + * 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() { + // 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; + } + delete this.captureCallback; + delete this.doneCallbacks; + delete this.options; + }, + + // Called when the timeout timer fires. + notify() { + this.timedOut = true; + this._browser.stop(); + }, + + _done(browser, imageData, reason, telemetry) { + // 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(); + + if (typeof reason != "number") { + throw new Error("A done reason must be given."); + } + + tel("CAPTURE_DONE_REASON_2", reason); + + if (telemetry) { + // Telemetry is currently disabled in the content process (bug 680508). + for (let id in telemetry) { + tel(id, telemetry[id]); + } + } + + let done = (info = null) => { + captureCallback(this, reason); + for (let callback of doneCallbacks) { + try { + callback.call(options, this.url, reason, info); + } catch (err) { + console.error(err); + } + } + + if (Services.prefs.getBoolPref(ABOUT_NEWTAB_SEGREGATION_PREF)) { + // Clear the data in the private container for thumbnails. + let privateIdentity = lazy.ContextualIdentityService.getPrivateIdentity( + "userContextIdInternal.thumbnail" + ); + if (privateIdentity) { + Services.clearData.deleteDataFromOriginAttributesPattern({ + userContextId: privateIdentity.userContextId, + }); + } + } + }; + + if (!imageData) { + done(); + return; + } + + this.readBlob(imageData).then(buffer => { + if (options.dontStore) { + done({ + data: buffer, + originalUrl: this.url, + finalUrl: browser.currentURI.spec, + contentType: options.contentType || PageThumbs.contentType, + }); + } else { + PageThumbs._store(this.url, browser.currentURI.spec, buffer, 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.dispatchToMainThread(callback); +} diff --git a/toolkit/components/thumbnails/PageThumbUtils.sys.mjs b/toolkit/components/thumbnails/PageThumbUtils.sys.mjs new file mode 100644 index 0000000000..61b81518cd --- /dev/null +++ b/toolkit/components/thumbnails/PageThumbUtils.sys.mjs @@ -0,0 +1,434 @@ +/* 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 BackgroundPageThumbs. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", +}); + +export var PageThumbUtils = { + // The default thumbnail size for images + THUMBNAIL_DEFAULT_SIZE: 448, + // 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. + * + * @param aWindow The document of this window will be used to + * create the canvas. + * @param aWidth (optional) width of the canvas to create + * @param aHeight (optional) height of the canvas to create + * @return The newly created canvas. + */ + createCanvas(aWindow, aWidth = 0, aHeight = 0) { + let doc = aWindow.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(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 primary monitor default scale might be different than + * what is reported by the window on mixed-DPI systems. + * To get the best image quality, query both and take the highest one. + */ + let primaryScale = screenManager.primaryScreen.defaultCSSScaleFactor; + let windowScale = aWindow ? aWindow.devicePixelRatio : primaryScale; + let scale = Math.max(primaryScale, windowScale); + + /** * + * 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(aWindow) { + let utils = aWindow.windowUtils; + let sbWidth = {}; + let sbHeight = {}; + + try { + utils.getScrollbarSize(false, sbWidth, sbHeight); + } catch (e) { + // This might fail if the window does not have a presShell. + console.error("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]; + }, + + /** + * Renders an image onto a new canvas of a given width and proportional + * height. Uses an image that exists in the window and is loaded, or falls + * back to loading the url into a new image element. + */ + async createImageThumbnailCanvas( + window, + url, + targetWidth = 448, + backgroundColor = this.THUMBNAIL_BG_COLOR + ) { + // 224px is the width of cards in ActivityStream; capture thumbnails at 2x + const doc = (window || Services.appShell.hiddenDOMWindow).document; + + let image = doc.querySelector("img"); + if (!image) { + image = doc.createElementNS(this.HTML_NAMESPACE, "img"); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("LOAD_FAILED")); + image.src = url; + }); + } + + // <img src="*.svg"> has width/height but not naturalWidth/naturalHeight + const imageWidth = image.naturalWidth || image.width; + const imageHeight = image.naturalHeight || image.height; + if (imageWidth === 0 || imageHeight === 0) { + throw new Error("IMAGE_ZERO_DIMENSION"); + } + const width = Math.min(targetWidth, imageWidth); + const height = (imageHeight * width) / imageWidth; + + // As we're setting the width and maintaining the aspect ratio, if an image + // is very tall we might get a very large thumbnail. Restricting the canvas + // size to {width}x{width} solves this problem. Here we choose to clip the + // image at the bottom rather than centre it vertically, based on an + // estimate that the focus of a tall image is most likely to be near the top + // (e.g., the face of a person). + const canvasHeight = Math.min(height, width); + const canvas = this.createCanvas(window, width, canvasHeight); + const context = canvas.getContext("2d"); + context.fillStyle = backgroundColor; + context.fillRect(0, 0, width, canvasHeight); + context.drawImage(image, 0, 0, width, height); + + return { + width, + height: canvasHeight, + imageData: canvas.toDataURL(), + }; + }, + + /** + * Given a browser, 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 aBrowser - the browser 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. + */ + async createSnapshotThumbnail(aBrowser, aDestCanvas, aArgs) { + const aWindow = aBrowser.contentWindow; + let backgroundColor = aArgs + ? aArgs.backgroundColor + : PageThumbUtils.THUMBNAIL_BG_COLOR; + 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); + const image = await aBrowser.drawSnapshot( + 0, + 0, + contentWidth, + contentHeight, + scale, + backgroundColor + ); + snapshotCtx.drawImage(image, 0, 0, contentWidth, contentHeight); + 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(aWindow, aCanvas) { + let utils = aWindow.windowUtils; + let sbWidth = {}; + let sbHeight = {}; + + try { + utils.getScrollbarSize(false, sbWidth, sbHeight); + } catch (e) { + // This might fail if the window does not have a presShell. + console.error("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(aDocument, aDocShell) { + if (lazy.BrowserUtils.isFindbarVisible(aDocShell)) { + return false; + } + + // FIXME Bug 720575 - Don't capture thumbnails for SVG or XML documents as + // that currently regresses Talos SVG tests. + if (ChromeUtils.getClassName(aDocument) === "XMLDocument") { + 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(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.sys.mjs b/toolkit/components/thumbnails/PageThumbs.sys.mjs new file mode 100644 index 0000000000..95f1f5e62e --- /dev/null +++ b/toolkit/components/thumbnails/PageThumbs.sys.mjs @@ -0,0 +1,887 @@ +/* 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 PREF_STORAGE_VERSION = "browser.pagethumbnails.storage_version"; +const LATEST_STORAGE_VERSION = 3; + +const EXPIRATION_MIN_CHUNK_SIZE = 50; +const EXPIRATION_INTERVAL_SECS = 3600; + +// 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"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { BasePromiseWorker } from "resource://gre/modules/PromiseWorker.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PageThumbUtils: "resource://gre/modules/PageThumbUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gUpdateTimerManager", + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "PageThumbsStorageService", + "@mozilla.org/thumbnails/pagethumbs-service;1", + "nsIPageThumbsStorageService" +); + +/** + * Utilities for dealing with promises. + */ +const TaskUtils = { + /** + * Read the bytes from a blob, asynchronously. + * + * @return {Promise} + * @resolve {ArrayBuffer} In case of success, the bytes contained in the blob. + * @reject {DOMException} In case of error, the underlying DOMException. + */ + readBlob: function readBlob(blob) { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.onloadend = function onloadend() { + if (reader.readyState != FileReader.DONE) { + reject(reader.error); + } else { + resolve(reader.result); + } + }; + reader.readAsArrayBuffer(blob); + }); + }, +}; + +/** + * Singleton providing functionality for capturing web page thumbnails and for + * accessing them if already cached. + */ +export var 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 "thumbnails"; + }, + + /** + * The thumbnails' image type. + */ + get contentType() { + return "image/png"; + }, + + init: function PageThumbs_init() { + if (!this._initialized) { + this._initialized = true; + + this._placesObserver = new PlacesWeakCallbackWrapper( + this.handlePlacesEvents.bind(this) + ); + PlacesObservers.addListener( + ["history-cleared", "page-removed"], + this._placesObserver + ); + + // Migrate the underlying storage, if needed. + PageThumbsStorageMigrator.migrate(); + PageThumbsExpiration.init(); + } + }, + + handlePlacesEvents(events) { + for (const event of events) { + switch (event.type) { + case "history-cleared": { + PageThumbsStorage.wipe(); + break; + } + case "page-removed": { + if (event.isRemovedFromStore) { + PageThumbsStorage.remove(event.url); + } + break; + } + } + } + }, + + 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 lazy.PageThumbsStorageService.getFilePathForURL(aUrl); + }, + + /** + * Asynchronously returns a thumbnail as a blob for the given + * window. + * + * @param aBrowser The <browser> to capture a thumbnail from. + * @param aArgs See captureToCanvas for accepted arguments. + * @return {Promise} + * @resolve {Blob} The thumbnail, as a Blob. + */ + captureToBlob: function PageThumbs_captureToBlob(aBrowser, aArgs) { + if (!this._prefEnabled()) { + return null; + } + + return new Promise(resolve => { + let canvas = this.createCanvas(aBrowser.ownerGlobal); + this.captureToCanvas(aBrowser, canvas, aArgs) + .then(() => { + canvas.toBlob(blob => { + resolve(blob, this.contentType); + }); + }) + .catch(e => console.error(e)); + }); + }, + + /** + * 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 aArgs (optional) Additional named parameters: + * fullScale - request that a non-downscaled image be returned. + * isImage - indicate that this should be treated as an image url. + * backgroundColor - background color to draw behind images. + * targetWidth - desired width for images. + * isBackgroundThumb - true if request is from the background thumb service. + * fullViewport - request that a screenshot for the viewport be + * captured. This makes it possible to get a screenshot that reflects + * the current scroll position of aBrowser. + * @param aSkipTelemetry skip recording telemetry + */ + async captureToCanvas(aBrowser, aCanvas, aArgs, aSkipTelemetry = false) { + let telemetryCaptureTime = new Date(); + let args = { + fullScale: aArgs ? aArgs.fullScale : false, + isImage: aArgs ? aArgs.isImage : false, + backgroundColor: + aArgs?.backgroundColor ?? lazy.PageThumbUtils.THUMBNAIL_BG_COLOR, + targetWidth: + aArgs?.targetWidth ?? lazy.PageThumbUtils.THUMBNAIL_DEFAULT_SIZE, + isBackgroundThumb: aArgs ? aArgs.isBackgroundThumb : false, + fullViewport: aArgs?.fullViewport ?? false, + }; + + return this._captureToCanvas(aBrowser, aCanvas, args).then(() => { + if (!aSkipTelemetry) { + Services.telemetry + .getHistogramById("FX_THUMBNAILS_CAPTURE_TIME_MS") + .add(new Date() - telemetryCaptureTime); + } + return 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 + */ + async shouldStoreThumbnail(aBrowser) { + // Don't capture in private browsing mode. + if (lazy.PrivateBrowsingUtils.isBrowserPrivate(aBrowser)) { + return false; + } + if (aBrowser.isRemoteBrowser) { + if (aBrowser.browsingContext.currentWindowGlobal) { + let thumbnailsActor = + aBrowser.browsingContext.currentWindowGlobal.getActor("Thumbnails"); + return thumbnailsActor + .sendQuery("Browser:Thumbnail:CheckState") + .catch(err => { + return false; + }); + } + return false; + } + return lazy.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. + async _captureToCanvas(aBrowser, aCanvas, aArgs) { + if (aBrowser.isRemoteBrowser) { + let thumbnail = await this._captureRemoteThumbnail( + aBrowser, + aCanvas.width, + aCanvas.height, + aArgs + ); + + // 'thumbnail' can be null if the browser has navigated away after starting + // the thumbnail request, so we check it here. + if (thumbnail) { + let ctx = thumbnail.getContext("2d"); + let imgData = ctx.getImageData(0, 0, thumbnail.width, thumbnail.height); + aCanvas.width = thumbnail.width; + aCanvas.height = thumbnail.height; + aCanvas.getContext("2d").putImageData(imgData, 0, 0); + } + + return aCanvas; + } + // The content is a local page, grab a thumbnail sync. + await lazy.PageThumbUtils.createSnapshotThumbnail(aBrowser, aCanvas, aArgs); + return 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. + * isImage - indicate that this should be treated as an image url. + * backgroundColor - background color to draw behind images. + * targetWidth - desired width for images. + * isBackgroundThumb - true if request is from the background thumb service. + * fullViewport - request that a screenshot for the viewport be + * captured. This makes it possible to get a screenshot that reflects + * the current scroll position of aBrowser. + * @return a promise + */ + async _captureRemoteThumbnail(aBrowser, aWidth, aHeight, aArgs) { + if (!aBrowser.browsingContext || !aBrowser.isConnected) { + return null; + } + + let thumbnailsActor = aBrowser.browsingContext.currentWindowGlobal.getActor( + aArgs.isBackgroundThumb ? "BackgroundThumbnails" : "Thumbnails" + ); + let contentInfo = await thumbnailsActor.sendQuery( + "Browser:Thumbnail:ContentInfo", + { + isImage: aArgs.isImage, + targetWidth: aArgs.targetWidth, + backgroundColor: aArgs.backgroundColor, + } + ); + + let contentWidth = contentInfo.width; + let contentHeight = contentInfo.height; + if (contentWidth == 0 || contentHeight == 0) { + throw new Error("IMAGE_ZERO_DIMENSION"); + } + + if (!aBrowser.isConnected) { + return null; + } + let doc = aBrowser.ownerDocument; + let thumbnail = doc.createElementNS( + lazy.PageThumbUtils.HTML_NAMESPACE, + "canvas" + ); + + let image; + if (contentInfo.imageData) { + thumbnail.width = contentWidth; + thumbnail.height = contentHeight; + + image = new aBrowser.ownerGlobal.Image(); + await new Promise(resolve => { + image.onload = resolve; + image.src = contentInfo.imageData; + }); + } else { + let fullScale = aArgs ? aArgs.fullScale : false; + let scale = fullScale + ? 1 + : Math.min(Math.max(aWidth / contentWidth, aHeight / contentHeight), 1); + + image = await aBrowser.drawSnapshot( + 0, + 0, + contentWidth, + contentHeight, + scale, + aArgs.backgroundColor, + aArgs.fullViewport + ); + if (!image) { + return null; + } + + thumbnail.width = fullScale ? contentWidth : aWidth; + thumbnail.height = fullScale ? contentHeight : aHeight; + } + + thumbnail.getContext("2d").drawImage(image, 0, 0); + + return thumbnail; + }, + + /** + * Captures a thumbnail for the given browser and stores it to the cache. + * @param aBrowser The browser to capture a thumbnail for. + */ + captureAndStore: async function PageThumbs_captureAndStore(aBrowser) { + if (!this._prefEnabled()) { + return; + } + + let url = aBrowser.currentURI.spec; + let originalURL; + let channelError = false; + + if (!aBrowser.isRemoteBrowser) { + let channel = aBrowser.docShell.currentDocumentChannel; + originalURL = channel.originalURI.spec; + // see if this was an error response. + channelError = lazy.PageThumbUtils.isChannelErrorResponse(channel); + } else { + let thumbnailsActor = + aBrowser.browsingContext.currentWindowGlobal.getActor("Thumbnails"); + let resp = await thumbnailsActor.sendQuery( + "Browser:Thumbnail:GetOriginalURL" + ); + + originalURL = resp.originalURL || url; + channelError = resp.channelError; + } + + try { + let blob = await this.captureToBlob(aBrowser); + let buffer = await TaskUtils.readBlob(blob); + await this._store(originalURL, url, buffer, channelError); + } catch (ex) { + console.error("Exception thrown during thumbnail capture: '", ex, "'"); + } + }, + + /** + * 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. + */ + captureAndStoreIfStale: async function PageThumbs_captureAndStoreIfStale( + aBrowser + ) { + if (!aBrowser.currentURI) { + return false; + } + let url = aBrowser.currentURI.spec; + let recent; + try { + recent = await PageThumbsStorage.isFileRecentForURL(url); + } catch { + return false; + } + 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 + ) { + await this.captureAndStore(aBrowser); + } + return true; + }, + + /** + * 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. + */ + _store: async function PageThumbs__store( + aOriginalURL, + aFinalURL, + aData, + aNoOverwrite + ) { + let telemetryStoreTime = new Date(); + await 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) { + await 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 lazy.PageThumbUtils.createCanvas(aWindow); + }, + + _prefEnabled: function PageThumbs_prefEnabled() { + try { + return !Services.prefs.getBoolPref( + "browser.pagethumbnails.capturing_disabled" + ); + } catch (e) { + return true; + } + }, +}; + +export var PageThumbsStorage = { + 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", [ + lazy.PageThumbsStorageService.path, + { ignoreExisting: true }, + ]).catch(function onError(aReason) { + console.error("Could not create thumbnails directory", aReason); + }); + }, + + _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 = lazy.PageThumbsStorageService.getFilePathForURL(aURL); + this.ensurePath(); + aData = new Uint8Array(aData); + let msg = [ + path, + aData, + { + tmpPath: path + ".tmp", + mode: aNoOverwrite ? "create" : "overwrite", + }, + ]; + 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 = + lazy.PageThumbsStorageService.getFilePathForURL(aSourceURL); + let targetFile = + lazy.PageThumbsStorageService.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", [ + lazy.PageThumbsStorageService.getFilePathForURL(aURL), + ]); + }, + + /** + * Remove all thumbnails, off the main thread. + * + * @return {Promise} + */ + wipe: 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 = () => undefined; + + // 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. + IOUtils.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", [ + lazy.PageThumbsStorageService.path, + ]); + try { + await promise; + } finally { + // Generally, we will be done much before profileBeforeChange, + // so let's not hoard blockers. + IOUtils.profileBeforeChange.removeBlocker(blocker); + } + }, + + fileExistsForURL: function Storage_fileExistsForURL(aURL) { + return PageThumbsWorker.post("exists", [ + lazy.PageThumbsStorageService.getFilePathForURL(aURL), + ]); + }, + + isFileRecentForURL: function Storage_isFileRecentForURL(aURL) { + return PageThumbsWorker.post("isFileRecent", [ + lazy.PageThumbsStorageService.getFilePathForURL(aURL), + MAX_THUMBNAIL_AGE_SECS, + ]); + }, + + /** + * For functions that take a noOverwrite option, IOUtils 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 IOUtils 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 || + !DOMException.isInstance(err) || + err.name !== "TypeMismatchError" + ) { + throw err; + } + }; + }, +}; + +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 = Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + roaming = Services.dirsvc.get("ProfD", Ci.nsIFile).path + ) { + PageThumbsWorker.post("moveOrDeleteAllThumbnails", [ + PathUtils.join(roaming, THUMBNAIL_DIRECTORY), + PathUtils.join(local, THUMBNAIL_DIRECTORY), + ]); + }, +}; + +// Export required for testing +export var PageThumbsExpiration = { + _filters: [], + + init: function Expiration_init() { + lazy.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 = () => { + this.expireThumbnails(urls); + }; + + // 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 => + lazy.PageThumbsStorageService.getLeafNameForURL(url) + ); + let msg = [ + lazy.PageThumbsStorageService.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" +); diff --git a/toolkit/components/thumbnails/PageThumbsStorageService.sys.mjs b/toolkit/components/thumbnails/PageThumbsStorageService.sys.mjs new file mode 100644 index 0000000000..c68390ac11 --- /dev/null +++ b/toolkit/components/thumbnails/PageThumbsStorageService.sys.mjs @@ -0,0 +1,67 @@ +/* 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 THUMBNAIL_DIRECTORY = "thumbnails"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "gCryptoHash", function () { + return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); +}); + +XPCOMUtils.defineLazyGetter(lazy, "gUnicodeConverter", function () { + let converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "utf8"; + return converter; +}); +export function PageThumbsStorageService() {} + +PageThumbsStorageService.prototype = { + classID: Components.ID("{97943eec-0e48-49ef-b7b7-cf4aa0109bb6}"), + QueryInterface: ChromeUtils.generateQI(["nsIPageThumbsStorageService"]), + // The path for the storage + _path: null, + get path() { + if (!this._path) { + this._path = PathUtils.join( + PathUtils.localProfileDir, + THUMBNAIL_DIRECTORY + ); + } + return this._path; + }, + + getLeafNameForURL(aURL) { + if (typeof aURL != "string") { + throw new TypeError("Expecting a string"); + } + let hash = this._calculateMD5Hash(aURL); + return hash + ".png"; + }, + + getFilePathForURL(aURL) { + return PathUtils.join(this.path, this.getLeafNameForURL(aURL)); + }, + + _calculateMD5Hash(aValue) { + let hash = lazy.gCryptoHash; + let value = lazy.gUnicodeConverter.convertToByteArray(aValue); + + hash.init(hash.MD5); + hash.update(value, value.length); + return this._convertToHexString(hash.finish(false)); + }, + + _convertToHexString(aData) { + let hex = ""; + for (let i = 0; i < aData.length; i++) { + hex += ("0" + aData.charCodeAt(i).toString(16)).slice(-2); + } + return hex; + }, +}; diff --git a/toolkit/components/thumbnails/PageThumbsWorker.js b/toolkit/components/thumbnails/PageThumbsWorker.js new file mode 100644 index 0000000000..11b48bdcf9 --- /dev/null +++ b/toolkit/components/thumbnails/PageThumbsWorker.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/. */ + +/* eslint-env mozilla/chrome-worker */ + +/** + * A worker dedicated for the I/O component of PageThumbs storage. + */ + +"use strict"; + +/* import-globals-from /toolkit/components/workerloader/require.js */ +importScripts("resource://gre/modules/workers/require.js"); + +var PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js"); + +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)); +self.addEventListener("unhandledrejection", function (error) { + throw error.reason; +}); + +var Agent = { + // Checks if the specified file exists and has an age less than as + // specifed (in seconds). + async isFileRecent(path, maxAge) { + try { + let stat = await IOUtils.stat(path); + let maxDate = new Date(); + maxDate.setSeconds(maxDate.getSeconds() - maxAge); + return stat.lastModified > maxDate; + } catch (ex) { + if (!(ex instanceof DOMException)) { + throw ex; + } + // file doesn't exist (or can't be stat'd) - must be stale. + return false; + } + }, + + async remove(path) { + try { + await IOUtils.remove(path); + return true; + } catch (e) { + return false; + } + }, + + async expireFilesInDirectory(path, filesToKeep, minChunkSize) { + let entries = await this.getFileEntriesInDirectory(path, filesToKeep); + let limit = Math.max(minChunkSize, Math.round(entries.length / 2)); + + for (let entry of entries) { + await this.remove(entry); + + // Check if we reached the limit of files to remove. + if (--limit <= 0) { + break; + } + } + + return true; + }, + + async getFileEntriesInDirectory(path, skipFiles) { + let children = await IOUtils.getChildren(path); + let skip = new Set(skipFiles); + + let entries = []; + for (let entry of children) { + let stat = await IOUtils.stat(entry); + if (stat.type === "regular" && !skip.has(PathUtils.filename(entry))) { + entries.push(entry); + } + } + return entries; + }, + + async moveOrDeleteAllThumbnails(pathFrom, pathTo) { + await IOUtils.makeDirectory(pathTo); + if (pathFrom == pathTo) { + return true; + } + let children = await IOUtils.getChildren(pathFrom); + for (let entry of children) { + let stat = await IOUtils.stat(entry); + if (stat.type !== "regular") { + continue; + } + + let fileName = PathUtils.filename(entry); + let from = PathUtils.join(pathFrom, fileName); + let to = PathUtils.join(pathTo, fileName); + + try { + await IOUtils.move(from, to, { noOverwrite: true }); + } catch (e) { + await IOUtils.remove(from); + } + } + + try { + await IOUtils.remove(pathFrom, { recursive: true }); + } catch (e) { + // This could fail if there's something in + // the folder we're not permitted to remove. + } + + return true; + }, + + writeAtomic(path, buffer, options) { + return IOUtils.write(path, buffer, options); + }, + + makeDir(path, options) { + return IOUtils.makeDirectory(path, options); + }, + + copy(source, dest, options) { + return IOUtils.copy(source, dest, options); + }, + + async wipe(path) { + let children = await IOUtils.getChildren(path); + try { + await Promise.all(children.map(entry => IOUtils.remove(entry))); + } 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. + } + }, + + exists(path) { + return IOUtils.exists(path); + }, +}; diff --git a/toolkit/components/thumbnails/components.conf b/toolkit/components/thumbnails/components.conf new file mode 100644 index 0000000000..d472b23bed --- /dev/null +++ b/toolkit/components/thumbnails/components.conf @@ -0,0 +1,14 @@ +# -*- 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/. + +Classes = [ + { + 'cid': '{97943eec-0e48-49ef-b7b7-cf4aa0109bb6}', + 'contract_ids': ['@mozilla.org/thumbnails/pagethumbs-service;1'], + 'esModule': 'resource://gre/modules/PageThumbsStorageService.sys.mjs', + 'constructor': 'PageThumbsStorageService', + }, +] diff --git a/toolkit/components/thumbnails/content/backgroundPageThumbs.xhtml b/toolkit/components/thumbnails/content/backgroundPageThumbs.xhtml new file mode 100644 index 0000000000..dca2841130 --- /dev/null +++ b/toolkit/components/thumbnails/content/backgroundPageThumbs.xhtml @@ -0,0 +1,15 @@ +<!DOCTYPE html> + +<!-- 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/. --> + +<!-- This page is used to host a (remote) browser for background page + thumbnailing purposes. It's always loaded as chrome:// . --> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta charset="utf-8" /> + <title>backgroundPageThumbs.html</title> + </head> + <body></body> +</html> diff --git a/toolkit/components/thumbnails/jar.mn b/toolkit/components/thumbnails/jar.mn new file mode 100644 index 0000000000..c2141dcb15 --- /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/backgroundPageThumbs.xhtml (content/backgroundPageThumbs.xhtml) diff --git a/toolkit/components/thumbnails/moz.build b/toolkit/components/thumbnails/moz.build new file mode 100644 index 0000000000..7469d39f30 --- /dev/null +++ b/toolkit/components/thumbnails/moz.build @@ -0,0 +1,35 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "New Tab Page") + +BROWSER_CHROME_MANIFESTS += ["test/browser.ini"] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell.ini"] + +EXTRA_JS_MODULES += [ + "BackgroundPageThumbs.sys.mjs", + "PageThumbs.sys.mjs", + "PageThumbsStorageService.sys.mjs", + "PageThumbsWorker.js", + "PageThumbUtils.sys.mjs", +] + +XPIDL_SOURCES += [ + "nsIPageThumbsStorageService.idl", +] + +LOCAL_INCLUDES += ["/netwerk/base"] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +XPIDL_MODULE = "thumbnails" + +FINAL_LIBRARY = "xul" + +JAR_MANIFESTS += ["jar.mn"] diff --git a/toolkit/components/thumbnails/nsIPageThumbsStorageService.idl b/toolkit/components/thumbnails/nsIPageThumbsStorageService.idl new file mode 100644 index 0000000000..bc49f5a751 --- /dev/null +++ b/toolkit/components/thumbnails/nsIPageThumbsStorageService.idl @@ -0,0 +1,30 @@ +/* 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/. */ + +#include "nsISupports.idl" + +/** + * A service which returns information about file paths where the + * screenshots for URLs are stored. These screenshots are used by the + * moz-page-thumb protocol + */ + +[scriptable, uuid(97943eec-0e48-49ef-b7b7-cf4aa0109bb6)] +interface nsIPageThumbsStorageService : nsISupports +{ + /** + * Returns the leaf name of the file containing the screenshot for a given URL + */ + AString getLeafNameForURL(in AString aURL); + + /** + * Returns the path where the thumbnails are stored + */ + readonly attribute AString path; + + /** + * Returns the full file path containing the screenshot for a given URL + */ + AString getFilePathForURL(in AString aURL); +}; diff --git a/toolkit/components/thumbnails/test/authenticate.sjs b/toolkit/components/thumbnails/test/authenticate.sjs new file mode 100644 index 0000000000..5bc811db74 --- /dev/null +++ b/toolkit/components/thumbnails/test/authenticate.sjs @@ -0,0 +1,216 @@ +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 Error("Couldn't parse auth header: " + authHeader); + } + + var userpass = atob(match[1]); + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw Error("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 Error("Couldn't parse auth header: " + authHeader); + } + + userpass = atob(match[1]); + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw Error("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 (let 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 (let 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 (let 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>"); +} diff --git a/toolkit/components/thumbnails/test/background_red.html b/toolkit/components/thumbnails/test/background_red.html new file mode 100644 index 0000000000..95159dd297 --- /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 0000000000..168772443d --- /dev/null +++ b/toolkit/components/thumbnails/test/background_red_redirect.sjs @@ -0,0 +1,13 @@ +/* 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 0000000000..1e30bd3c67 --- /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 0000000000..8191b83de2 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser.ini @@ -0,0 +1,52 @@ +[DEFAULT] +support-files = + authenticate.sjs + background_red.html + background_red_redirect.sjs + background_red_scroll.html + sample_image_red_1920x1080.jpg + sample_image_green_1024x1024.jpg + sample_image_blue_300x600.jpg + head.js + privacy_cache_control.sjs + thumbnails_background.sjs + thumbnails_update.sjs + +[browser_thumbnails_bg_bad_url.js] +[browser_thumbnails_bg_crash_during_capture.js] +skip-if = !crashreporter + apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs +[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] +skip-if = verify +[browser_thumbnails_bg_no_cookies_stored.js] +skip-if = verify +[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_bg_image_capture.js] +[browser_thumbnails_bg_topsites.js] +[browser_thumbnails_bug726727.js] +[browser_thumbnails_bug727765.js] +[browser_thumbnails_bug818225.js] +skip-if = (verify && debug && (os == 'linux')) +[browser_thumbnails_capture.js] +[browser_thumbnails_capture_parent_process.js] +skip-if = true # bug 1314039 # Bug 1345418 +[browser_thumbnails_expiration.js] +[browser_thumbnails_fullViewport.js] +[browser_thumbnails_privacy.js] +[browser_thumbnails_redirect.js] +[browser_thumbnails_removed_tab.js] +[browser_thumbnails_storage.js] +[browser_thumbnails_storage_migrate3.js] +skip-if = true # Bug 1592079 +[browser_thumbnails_update.js] +skip-if = os == 'win' #Bug 1539947 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 0000000000..7ebb843cd3 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_bad_url.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_bad_url() { + let url = "invalid-protocol://ffggfsdfsdf/"; + ok(!thumbnailExists(url), "Thumbnail should not be cached already."); + let numCalls = 0; + let observer = bgAddPageThumbObserver(url); + await new Promise(resolve => { + 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" + ); + resolve(); + }, + }); + }); + + await Assert.rejects(observer, /page-thumbnail:error/); +}); 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 0000000000..1adb2f03f1 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_basic.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_basic() { + let url = "http://www.example.com/"; + ok(!thumbnailExists(url), "Thumbnail should not be cached yet."); + + let capturePromise = bgAddPageThumbObserver(url); + let [capturedURL] = await bgCapture(url); + is(capturedURL, url, "Captured URL should be URL passed to capture"); + await 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 0000000000..a5ccafc801 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_captureIfMissing.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_captureIfMissing() { + 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"); + + let url = "https://example.com/"; + let file = thumbnailFile(url); + ok(!file.exists(), "Thumbnail file should not already exist."); + + let [capturedURL] = await 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] = await 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 0000000000..872965d7b2 --- /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/ */ + +add_task(async function thumbnails_bg_crash_during_capture() { + // make a good capture first - this ensures we have the <browser> + let goodUrl = bgTestPageURL(); + await bgCapture(goodUrl); + ok(thumbnailExists(goodUrl), "Thumbnail should be cached after capture"); + removeThumbnail(goodUrl); + + // 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; + let failCapture = bgCapture(waitUrl, { + onDone: () => { + sawWaitUrlCapture = true; + ok( + !thumbnailExists(waitUrl), + "Thumbnail should not have been saved due to the crash" + ); + }, + }); + let goodCapture = 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); + }, + }); + + let crashPromise = bgAddPageThumbObserver(waitUrl).catch(err => { + ok(/page-thumbnail:error/.test(err), "Got the right kind of error"); + }); + let capturePromise = bgAddPageThumbObserver(goodUrl); + + info("Crashing the thumbnail content process."); + let crash = await BrowserTestUtils.crashFrame( + BackgroundPageThumbs._thumbBrowser, + false + ); + ok(crash.CrashTime, "Saw a crash from this test"); + + await crashPromise; + await Promise.all([failCapture, goodCapture, 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 0000000000..4808f39e1c --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_crash_while_idle.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_crash_while_idle() { + // make a good capture first - this ensures we have the <browser> + let goodUrl = bgTestPageURL(); + await bgCapture(goodUrl); + ok(thumbnailExists(goodUrl), "Thumbnail should be cached after capture"); + removeThumbnail(goodUrl); + + // Nothing is pending - crash the process. + info("Crashing the thumbnail content process."); + let crash = await BrowserTestUtils.crashFrame( + BackgroundPageThumbs._thumbBrowser, + false + ); + ok(crash.CrashTime, "Saw a crash from this test"); + + // Now queue another capture and ensure it recovers. + await bgCapture(goodUrl, { + onDone: () => { + ok( + thumbnailExists(goodUrl), + "We should have recovered and handled new capture requests" + ); + removeThumbnail(goodUrl); + }, + }); +}); 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 0000000000..609d344972 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_destroy_browser.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_destroy_browser() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]], + }); + + let url1 = "https://example.com/1"; + ok(!thumbnailExists(url1), "First file should not exist yet."); + + let url2 = "https://example.com/2"; + ok(!thumbnailExists(url2), "Second file should not exist yet."); + + let defaultTimeout = BackgroundPageThumbs._destroyBrowserTimeout; + BackgroundPageThumbs._destroyBrowserTimeout = 1000; + + await bgCapture(url1); + ok(thumbnailExists(url1), "First file should exist after capture."); + removeThumbnail(url1); + + // arbitrary wait - intermittent failures noted after 2 seconds + await TestUtils.waitForCondition( + () => { + return BackgroundPageThumbs._thumbBrowser === undefined; + }, + "BackgroundPageThumbs._thumbBrowser should eventually be discarded.", + 1000, + 5 + ); + + is( + BackgroundPageThumbs._thumbBrowser, + undefined, + "Thumb browser should be destroyed after timeout." + ); + BackgroundPageThumbs._destroyBrowserTimeout = defaultTimeout; + + await 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_image_capture.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_image_capture.js new file mode 100644 index 0000000000..03f30c544a --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_image_capture.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const BASE_URL = + "http://mochi.test:8888/browser/toolkit/components/thumbnails/"; + +/** + * These tests ensure that when trying to capture a url that is an image file, + * the image itself is captured instead of the the browser window displaying the + * image, and that the thumbnail maintains the image aspect ratio. + */ +add_task(async function thumbnails_bg_image_capture() { + // Test that malformed input causes _finishCurrentCapture to be called with + // the correct reason. + const emptyUrl = "data:text/plain,"; + await bgCapture(emptyUrl, { + isImage: true, + onDone: (url, reason) => { + // BackgroundPageThumbs.TEL_CAPTURE_DONE_LOAD_FAILED === 6 + is(reason, 6, "Should have the right failure reason"); + }, + }); + + for (const { url, color, width, height } of [ + { + url: BASE_URL + "test/sample_image_red_1920x1080.jpg", + color: [255, 0, 0], + width: 1920, + height: 1080, + }, + { + url: BASE_URL + "test/sample_image_green_1024x1024.jpg", + color: [0, 255, 0], + width: 1024, + height: 1024, + }, + { + url: BASE_URL + "test/sample_image_blue_300x600.jpg", + color: [0, 0, 255], + width: 300, + height: 600, + }, + ]) { + dontExpireThumbnailURLs([url]); + const capturedPromise = bgAddPageThumbObserver(url); + await bgCapture(url); + await capturedPromise; + ok(thumbnailExists(url), "The image thumbnail should exist after capture"); + + const thumb = PageThumbs.getThumbnailURL(url); + const htmlns = "http://www.w3.org/1999/xhtml"; + const img = document.createElementNS(htmlns, "img"); + img.src = thumb; + await BrowserTestUtils.waitForEvent(img, "load"); + + // 448px is the default max-width of an image thumbnail + const expectedWidth = Math.min(448, width); + // Tall images are clipped to {width}x{width} + const expectedHeight = Math.min( + (expectedWidth * height) / width, + expectedWidth + ); + // Fuzzy equality to account for rounding + ok( + Math.abs(img.naturalWidth - expectedWidth) <= 1, + "The thumbnail should have the right width" + ); + ok( + Math.abs(img.naturalHeight - expectedHeight) <= 1, + "The thumbnail should have the right height" + ); + + // Draw the image to a canvas and compare the pixel color values. + const canvas = document.createElementNS(htmlns, "canvas"); + canvas.width = expectedWidth; + canvas.height = expectedHeight; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, expectedWidth, expectedHeight); + const [r, g, b] = ctx.getImageData( + 0, + 0, + expectedWidth, + expectedHeight + ).data; + // Fuzzy equality to account for image encoding + ok( + Math.abs(r - color[0]) <= 2 && + Math.abs(g - color[1]) <= 2 && + Math.abs(b - color[2]) <= 2, + "The thumbnail should have the right color" + ); + + removeThumbnail(url); + } +}); 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 0000000000..e0dc7f353c --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_alert.js @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_no_alert() { + let url = + "data:text/html,<script>try { alert('yo!'); } catch (e) {}</script>"; + ok(!thumbnailExists(url), "Thumbnail file should not already exist."); + + let [capturedURL] = await 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 0000000000..e9dd51fa7d --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_auth_prompt.js @@ -0,0 +1,26 @@ +/* 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?) + */ +add_task(async function thumbnails_bg_no_auth_prompt() { + 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] = await 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 0000000000..9558e98223 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_sent.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_no_cookies_sent() { + // Visit the test page in the browser and tell it to set a cookie. + let url = bgTestPageURL({ setGreenCookie: true }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async browser => { + // The root element of the page shouldn't be green yet. + await SpecialPowers.spawn(browser, [], 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(); + await BrowserTestUtils.browserLoaded(browser); + await SpecialPowers.spawn(browser, [], 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.) + await bgCapture(url); + ok(thumbnailExists(url), "Thumbnail file should exist after capture."); + + let [r, g, b] = await retrieveImageDataForURL(url); + isnot( + [r, g, b].toString(), + [0, 255, 0].toString(), + "The captured page should not be green." + ); + removeThumbnail(url); + } + ); +}); 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 0000000000..f7acf96ba9 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_cookies_stored.js @@ -0,0 +1,39 @@ +/* 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. + */ +add_task(async function thumbnails_bg_no_cookies_stored() { + await 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."); + await 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. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async browser => { + // The root element of the page shouldn't be red. + await SpecialPowers.spawn(browser, [], function () { + Assert.notEqual( + content.document.documentElement.style.backgroundColor, + "rgb(255, 0, 0)", + "The page shouldn't be red." + ); + }); + } + ); +}); 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 0000000000..e4b3b42849 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_no_duplicates.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_no_duplicates() { + let url = "https://example.com/1"; + ok(!thumbnailExists(url), "Thumbnail file should not already exist."); + + let firstCapture = bgCapture(url, { + onDone: doneUrl => { + is(doneUrl, url, "called back with correct url"); + ok(thumbnailExists(url), "Thumbnail file should now exist."); + removeThumbnail(url); + }, + }); + + let secondCapture = bgCapture(url, { + onDone: doneUrl => { + is(doneUrl, url, "called back with correct url"); + ok(!thumbnailExists(url), "Thumbnail file should still be deleted."); + }, + }); + + await Promise.all([firstCapture, secondCapture]); +}); 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 0000000000..92e8711637 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_queueing.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_queueing() { + let urls = [ + "https://www.example.com/0", + "https://www.example.com/1", + // an item that will timeout to ensure timeouts work and we resume. + bgTestPageURL({ wait: 2002 }), + "https://www.example.com/2", + ]; + dontExpireThumbnailURLs(urls); + + let promises = []; + + for (let url of urls) { + ok(!thumbnailExists(url), "Thumbnail should not exist yet: " + url); + let isTimeoutTest = url.includes("wait"); + + let promise = bgCapture(url, { + timeout: isTimeoutTest ? 100 : 30000, + onDone: capturedURL => { + ok(!!urls.length, "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); + } + }, + }); + + promises.push(promise); + } + + await Promise.all(promises); +}); 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 0000000000..ae0096ff18 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_redirect.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_redirect() { + let finalURL = "https://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 = bgAddPageThumbObserver(originalURL); + let captureFinalPromise = bgAddPageThumbObserver(finalURL); + + let [capturedURL] = await bgCapture(originalURL); + is(capturedURL, originalURL, "Captured URL should be URL passed to capture"); + await captureOriginalPromise; + await 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 0000000000..4deabaf325 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_timeout.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bg_timeout() { + let url = bgTestPageURL({ wait: 30000 }); + ok(!thumbnailExists(url), "Thumbnail should not be cached already."); + let numCalls = 0; + let thumbnailErrorPromise = bgAddPageThumbObserver(url); + + await bgCapture(url, { + timeout: 0, + 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" + ); + }, + }); + + await Assert.rejects(thumbnailErrorPromise, /page-thumbnail:error/); +}); diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_bg_topsites.js b/toolkit/components/thumbnails/test/browser_thumbnails_bg_topsites.js new file mode 100644 index 0000000000..fac37aff4a --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bg_topsites.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const image1x1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; +const image96x96 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAJCAYAAADNYymqAAAAKklEQVR42u3RMQEAAAjDMFCO9CGDg1RC00lN6awGAACADQAACAAAAXjXApPGFm+IdJG9AAAAAElFTkSuQmCC"; +const baseURL = "http://mozilla${i}.com/"; + +add_task(async function thumbnails_bg_topsites() { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts", + false, + ], + ], + }); + // Add 3 top sites - 2 visits each so it can pass frecency threshold of the top sites query + for (let i = 1; i <= 3; i++) { + await PlacesTestUtils.addVisits(baseURL.replace("${i}", i)); + await PlacesTestUtils.addVisits(baseURL.replace("${i}", i)); + } + + // Add favicon data for 2 of the top sites + let faviconData = new Map(); + faviconData.set("http://mozilla1.com/", image1x1); + faviconData.set("http://mozilla2.com/", image96x96); + await PlacesTestUtils.addFavicons(faviconData); + + // Sanity check that we've successfully added all 3 urls to top sites + let links = await NewTabUtils.activityStreamLinks.getTopSites(); + is( + links[0].url, + baseURL.replace("${i}", 3), + "Top site has been successfully added" + ); + is( + links[1].url, + baseURL.replace("${i}", 2), + "Top site has been successfully added" + ); + is( + links[2].url, + baseURL.replace("${i}", 1), + "Top site has been successfully added" + ); + + // Now, add a pinned site so we can also fetch a screenshot for that + const pinnedSite = { url: baseURL.replace("${i}", 4) }; + NewTabUtils.pinnedLinks.pin(pinnedSite, 0); + + // Check that the correct sites will capture screenshots + gBrowserThumbnails.clearTopSiteURLCache(); + let topSites = await gBrowserThumbnails._topSiteURLs; + ok( + topSites.includes("http://mozilla1.com/"), + "Top site did not have a rich icon - get a screenshot" + ); + ok( + topSites.includes("http://mozilla3.com/"), + "Top site did not have an icon - get a screenshot" + ); + ok( + topSites.includes("http://mozilla4.com/"), + "Site is pinned - get a screenshot" + ); + ok( + !topSites.includes("http://mozilla2.com/"), + "Top site had a rich icon - do not get a screenshot" + ); + + // Clean up + NewTabUtils.pinnedLinks.unpin(pinnedSite); + await PlacesUtils.history.clear(); +}); 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 0000000000..b7fc5f0ac6 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bug726727.js @@ -0,0 +1,27 @@ +/* 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. + */ +add_task(async function thumbnails_bg_bug726727() { + // Create a tab that shows an error page. + await BrowserTestUtils.withNewTab( + { + gBrowser, + }, + async browser => { + let errorPageLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + null, + true + ); + BrowserTestUtils.loadURIString(browser, "http://127.0.0.1:1"); + await errorPageLoaded; + let result = await PageThumbs.shouldStoreThumbnail(browser); + ok(!result, "we're not going to capture an error page"); + } + ); +}); 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 0000000000..8e953e0ffa --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_bug727765.js @@ -0,0 +1,38 @@ +/* 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. +add_task(async function thumbnails_bg_bug727765() { + // Create a tab with a page with a red background and scrollbars. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + await captureAndCheckColor(255, 0, 0, "we have a red thumbnail"); + + // Check the thumbnail color of the bottom right pixel. + await whenFileExists(URL); + + let data = await retrieveImageDataForURL(URL); + let [r, g, b] = [].slice.call(data, -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); + } + ); +}); 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 0000000000..5f557c40a4 --- /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 +add_task(async function thumbnails_bg_bug818225() { + let path = PageThumbs.getThumbnailPath(URL); + await testIfExists(path, false, "Thumbnail file does not exist"); + await promiseAddVisitsAndRepopulateNewTabLinks(URL); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + gBrowserThumbnails.clearTopSiteURLCache(); + await whenFileExists(URL); + } + ); + + path = PageThumbs.getThumbnailPath(URL); + let expectedPath = PageThumbsStorageService.getFilePathForURL(URL); + is(path, expectedPath, "Thumbnail file has correct path"); + + await testIfExists(path, true, "Thumbnail file exists"); +}); + +function testIfExists(aPath, aExpected, aMessage) { + return IOUtils.exists(aPath).then( + function onSuccess(exists) { + is(exists, aExpected, aMessage); + }, + function onFailure(error) { + ok(false, `IOUtils.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 0000000000..1fe7a66fe5 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_capture.js @@ -0,0 +1,37 @@ +/* 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. + */ +add_task(async function thumbnails_capture() { + // Create a tab with a red background. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "data:text/html,<body bgcolor=ff0000></body>", + }, + async browser => { + await captureAndCheckColor(255, 0, 0, "we have a red thumbnail"); + + // Load a page with a green background. + let loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString( + browser, + "data:text/html,<body bgcolor=00ff00></body>" + ); + await loaded; + await captureAndCheckColor(0, 255, 0, "we have a green thumbnail"); + + // Load a page with a blue background. + loaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString( + browser, + "data:text/html,<body bgcolor=0000ff></body>" + ); + await loaded; + await captureAndCheckColor(0, 0, 255, "we have a blue thumbnail"); + } + ); +}); diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_capture_parent_process.js b/toolkit/components/thumbnails/test/browser_thumbnails_capture_parent_process.js new file mode 100644 index 0000000000..67cd6a79e2 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_capture_parent_process.js @@ -0,0 +1,27 @@ +/* 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, specifically for a parent process. + */ +add_task(async function thumbnails_capture() { + // Create a parent process tab with a red background. + // We do this by creating a parent process, then we update it to be a red page, + // before attempting to read the page colour. + await BrowserTestUtils.withNewTab( + { + gBrowser, + // about:robots seems to be an simple parent process url we can test against, + // but any parent process url would have worked, example, about:home or about:config + url: "about:robots", + }, + async browser => { + // Because about:robots is not generally a predictable testable page, + // we update its background to something we can test against. + browser.contentDocument.body.innerHTML = ""; + browser.contentDocument.body.style.backgroundColor = "#ff0000"; + await captureAndCheckColor(255, 0, 0, "we have a red 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 0000000000..4be93ebb16 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_expiration.js @@ -0,0 +1,78 @@ +/* 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"; + +const EXPIRATION_MIN_CHUNK_SIZE = 50; +const { PageThumbsExpiration } = ChromeUtils.importESModule( + "resource://gre/modules/PageThumbs.sys.mjs" +); + +add_task(async function thumbnails_expiration() { + // 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. + await createDummyThumbnail(URL1); + ok(thumbnailExists(URL1), "first thumbnail created"); + + await createDummyThumbnail(URL2); + ok(thumbnailExists(URL2), "second thumbnail created"); + + await createDummyThumbnail(URL3); + ok(thumbnailExists(URL3), "third thumbnail created"); + + // Remove the third thumbnail. + await 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. + await expireThumbnails([URL1]); + ok(thumbnailExists(URL1), "first thumbnail still exists"); + ok(!thumbnailExists(URL2), "second thumbnail has been removed"); + + // Remove all thumbnails. + await expireThumbnails([]); + ok(!thumbnailExists(URL1), "all thumbnails have been removed"); + + // Create some more files than the min chunk size. + for (let url of dummyURLs) { + await createDummyThumbnail(url); + } + + ok(dummyURLs.every(thumbnailExists), "all dummy thumbnails created"); + + // Expire thumbnails and expect 10 remaining. + await expireThumbnails([]); + let remainingURLs = dummyURLs.filter(thumbnailExists); + is(remainingURLs.length, 10, "10 dummy thumbnails remaining"); + + // Expire thumbnails again. All should be gone by now. + await 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; + } + return PageThumbsStorage.writeData(aURL, dummy); +} + +function expireThumbnails(aKeep) { + return PageThumbsExpiration.expireThumbnails(aKeep); +} diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_fullViewport.js b/toolkit/components/thumbnails/test/browser_thumbnails_fullViewport.js new file mode 100644 index 0000000000..92533bdd3d --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_fullViewport.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const BUILDER_URL = "https://example.com/document-builder.sjs?html="; +const PAGE_MARKUP = ` +<html> + <head> + <style> + body { + background-color: rgb(0, 255, 0); + margin: 0; + } + + div { + background-color: rgb(255, 0, 0); + height: 100vh; + width: 100vw; + margin-top: 100vh; + } + </style> + </head> + <body> + <div id="bigredblock"></div> + </body> +</html> +`; +const PAGE_URL = BUILDER_URL + encodeURI(PAGE_MARKUP); + +/** + * These tests ensure that it's possible to capture the full viewport of + * a browser, and not just the top region. + */ +add_task(async function thumbnails_fullViewport_capture() { + // Create a tab with a green background. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE_URL, + }, + async browser => { + let canvas = PageThumbs.createCanvas(window); + await PageThumbs.captureToCanvas(browser, canvas, { + fullViewport: true, + }); + + // The red region isn't scrolled in yet, so we should get + // a green thumbnail. + let ctx = canvas.getContext("2d"); + let [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + Assert.equal(r, 0, "No red channel"); + Assert.equal(g, 255, "Full green channel"); + Assert.equal(b, 0, "No blue channel"); + + // Now scroll the red region into view. + await SpecialPowers.spawn(browser, [], () => { + let redblock = content.document.getElementById("bigredblock"); + redblock.scrollIntoView(true); + }); + + await PageThumbs.captureToCanvas(browser, canvas, { + fullViewport: true, + }); + + // The captured region should be red. + ctx = canvas.getContext("2d"); + [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + Assert.equal(r, 255, "Full red channel"); + Assert.equal(g, 0, "No green channel"); + Assert.equal(b, 0, "No blue channel"); + } + ); +}); 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 0000000000..d03297f69d --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_privacy.js @@ -0,0 +1,72 @@ +/* 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"; + +add_task(async function thumbnails_privacy() { + 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 }, + ]; + + await checkCombinations(positive, true); + await checkCombinations(negative, false); +}); + +async function checkCombinations(aCombinations, aResult) { + for (let combination of aCombinations) { + let url = combination.scheme + URL; + if (combination.cacheControl) { + url += "?" + combination.cacheControl; + } + + await SpecialPowers.pushPrefEnv({ + set: [[PREF_DISK_CACHE_SSL, combination.diskCacheSSL]], + }); + + // Add the test page as a top link so it has a chance to be thumbnailed + await promiseAddVisitsAndRepopulateNewTabLinks(url); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async browser => { + let msg = JSON.stringify(combination) + " == " + aResult; + let aIsSafeSite = await PageThumbs.shouldStoreThumbnail(browser); + Assert.equal(aIsSafeSite, aResult, msg); + } + ); + + await SpecialPowers.popPrefEnv(); + } +} 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 0000000000..b77b4011c3 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_redirect.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_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. + */ +add_task(async function thumbnails_redirect() { + dontExpireThumbnailURLs([URL, FINAL_URL]); + + // Kick off history by loading a tab first or the test fails in single mode. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + browser => {} + ); + + // Create a tab, redirecting to a page with a red background. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + await captureAndCheckColor(255, 0, 0, "we have a red thumbnail"); + + // Wait until the referrer's thumbnail's file has been written. + await whenFileExists(URL); + let [r, g, b] = await retrieveImageDataForURL(URL); + is("" + [r, g, b], "255,0,0", "referrer has a red thumbnail"); + } + ); +}); diff --git a/toolkit/components/thumbnails/test/browser_thumbnails_removed_tab.js b/toolkit/components/thumbnails/test/browser_thumbnails_removed_tab.js new file mode 100644 index 0000000000..9d2138d3d0 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_removed_tab.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function thumbnails_bug1775638() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + const canvas = document.createElement("canvas"); + const promise = PageThumbs.captureToCanvas(tab.linkedBrowser, canvas); + gBrowser.removeTab(tab); + is(await promise, canvas); +}); 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 0000000000..6a9f1ed3f6 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_storage.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/"; +const URL_COPY = URL + "#copy"; + +/** + * 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. + */ +add_task(async function thumbnails_storage() { + dontExpireThumbnailURLs([URL, URL_COPY]); + + await promiseClearHistory(); + await promiseAddVisitsAndRepopulateNewTabLinks(URL); + await promiseCreateThumbnail(); + + // Make sure Storage.copy() updates an existing file. + await PageThumbsStorage.copy(URL, URL_COPY); + let copy = new FileUtils.File( + PageThumbsStorageService.getFilePathForURL(URL_COPY) + ); + let mtime = (copy.lastModifiedTime -= 60); + + await PageThumbsStorage.copy(URL, URL_COPY); + isnot( + new FileUtils.File(PageThumbsStorageService.getFilePathForURL(URL_COPY)) + .lastModifiedTime, + mtime, + "thumbnail file was updated" + ); + + let file = new FileUtils.File( + PageThumbsStorageService.getFilePathForURL(URL) + ); + let fileCopy = new FileUtils.File( + PageThumbsStorageService.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()) { + await promiseClearHistory(); + } + info("History is clear"); + + info("Repopulating"); + await promiseAddVisitsAndRepopulateNewTabLinks(URL); + await promiseCreateThumbnail(); + + info("Clearing the last 10 minutes of browsing history"); + // Clear the last 10 minutes of browsing history. + await promiseClearHistory(true); + + info("Attempt to clear file"); + // Retry until the file is gone because Windows locks it sometimes. + await promiseClearFile(file, URL); + + info("Done"); +}); + +async function promiseClearFile(aFile, aURL) { + if (!aFile.exists()) { + return undefined; + } + // Re-add our URL to the history so that history observer's onDeleteURI() + // is called again. + await PlacesTestUtils.addVisits(makeURI(aURL)); + await promiseClearHistory(true); + // Then retry. + return promiseClearFile(aFile, aURL); +} + +function promiseClearHistory(aUseRange) { + let options = {}; + if (aUseRange) { + let usec = Date.now() * 1000; + options.range = [usec - 10 * 60 * 1000 * 1000, usec]; + options.ignoreTimespan = false; + } + return Sanitizer.sanitize(["history"], options); +} + +async function promiseCreateThumbnail() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + gBrowserThumbnails.clearTopSiteURLCache(); + await whenFileExists(URL); + } + ); +} 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 0000000000..c2917e9dd7 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_storage_migrate3.js @@ -0,0 +1,95 @@ +/* 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 = Cu.Sandbox(window, { wantGlobalProperties: ["ChromeUtils"] }); +Services.scriptloader.loadSubScript( + "resource://gre/modules/PageThumbs.jsm", + tmp +); +var { PageThumbsStorageMigrator } = tmp; + +/** + * 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 = PageThumbsStorageService.getLeafNameForURL(URL); + let file = FileUtils.getFile("ProfD", [THUMBNAIL_DIRECTORY, name]); + writeDummyFile(file); + + name = PageThumbsStorageService.getLeafNameForURL(URL2); + file = FileUtils.getFile("ProfD", [THUMBNAIL_DIRECTORY, name]); + writeDummyFile(file); + + name = PageThumbsStorageService.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 = PageThumbsStorageService.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" + ); +} + +function changeLocation(aLocation, aNewDir) { + let oldDir = Services.dirsvc.get(aLocation, Ci.nsIFile); + Services.dirsvc.undefine(aLocation); + Services.dirsvc.set(aLocation, aNewDir); + + registerCleanupFunction(function () { + Services.dirsvc.undefine(aLocation); + Services.dirsvc.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 0000000000..89e085d1d9 --- /dev/null +++ b/toolkit/components/thumbnails/test/browser_thumbnails_update.js @@ -0,0 +1,211 @@ +/* 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 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 = PageThumbsStorageService.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 = PageThumbsStorageService.getFilePathForURL(url); + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(fname); + return file.lastModifiedTime; +} + +/** + * Check functionality of a normal captureAndStoreIfStale request + */ +add_task(async function thumbnails_captureAndStoreIfStale_normal() { + const URL = + "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?simple"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + let numNotifications = 0; + + let observed = TestUtils.topicObserved( + "page-thumbnail:create", + (subject, data) => { + is(data, URL, "data is our test URL"); + + // Once we get the second notification, we saw the last captureAndStoreIsStale, + // and we can tear down. + if (++numNotifications == 2) { + return true; + } + + return false; + } + ); + await PageThumbs.captureAndStore(browser); + // We've got a capture so should have seen the observer. + is(numNotifications, 1, "got notification of item being created."); + + await PageThumbs.captureAndStoreIfStale(browser); + is( + numNotifications, + 1, + "still only 1 notification of item being created." + ); + + ensureThumbnailStale(URL); + await PageThumbs.captureAndStoreIfStale(browser); + await observed; + } + ); +}); + +/** + * Check functionality of captureAndStoreIfStale when there is an error response + * from the server. + */ +add_task(async function thumbnails_captureAndStoreIfStale_error_response() { + const URL = + "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?fail"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + await 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); + BrowserTestUtils.loadURIString(browser, URL); + await BrowserTestUtils.browserLoaded(browser); + + // 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; + await PageThumbs.captureAndStoreIfStale(gBrowser.selectedBrowser); + + ok(getThumbnailModifiedTime(URL) < now, "modified time should be < now"); + let [r, g, b] = await retrieveImageDataForURL(URL); + is("" + [r, g, b], "" + [0, 255, 0], "thumbnail is still green"); + } + ); +}); + +/** + * 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. + */ +add_task(async function thumbnails_captureAndStoreIfStale_non_error_response() { + const URL = + "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?ok"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + await 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); + BrowserTestUtils.loadURIString(browser, URL); + await BrowserTestUtils.browserLoaded(browser); + + // 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; + await PageThumbs.captureAndStoreIfStale(browser); + Assert.greater( + getThumbnailModifiedTime(URL), + now, + "modified time should be >= now" + ); + let [r, g, b] = await retrieveImageDataForURL(URL); + is("" + [r, g, b], "" + [255, 0, 0], "thumbnail is now red"); + } + ); +}); + +/** + * Check functionality of captureAndStore when there is an error response + * from the server. + */ +add_task(async function thumbnails_captureAndStore_error_response() { + const URL = + "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?fail"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + await captureAndCheckColor(0, 255, 0, "we have a green thumbnail"); + } + ); + + // do it again - the server will return a 400, so the foreground service + // should not update it. + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + await captureAndCheckColor(0, 255, 0, "we still have a green thumbnail"); + } + ); +}); + +/** + * Check functionality of captureAndStore when there is an OK response + * from the server. + */ +add_task(async function thumbnails_captureAndStore_ok_response() { + const URL = + "http://mochi.test:8888/browser/toolkit/components/thumbnails/test/thumbnails_update.sjs?ok"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + await captureAndCheckColor(0, 255, 0, "we have a green thumbnail"); + } + ); + + // do it again - the server will return a 200, so the foreground service + // should update it. + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: URL, + }, + async browser => { + await 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 0000000000..a531395bed --- /dev/null +++ b/toolkit/components/thumbnails/test/head.js @@ -0,0 +1,235 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + BackgroundPageThumbs: "resource://gre/modules/BackgroundPageThumbs.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + PageThumbsStorage: "resource://gre/modules/PageThumbs.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "PageThumbsStorageService", + "@mozilla.org/thumbnails/pagethumbs-service;1", + "nsIPageThumbsStorageService" +); + +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 + ); +}); + +/** + * 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. + */ +async 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. + await PageThumbs.captureAndStore(browser); + let [r, g, b] = await retrieveImageDataForURL(browser.currentURI.spec); + is("" + [r, g, b], "" + [aRed, aGreen, aBlue], aMessage); +} + +/** + * 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. + * @returns Promise + */ +async function retrieveImageDataForURL(aURL) { + 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); + await BrowserTestUtils.waitForEvent(img, "load", true); + + 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); + return ctx.getImageData(0, 0, 100, 100).data; +} + +/** + * Returns the file of the thumbnail with the given URL. + * @param aURL The URL of the thumbnail. + */ +function thumbnailFile(aURL) { + return new FileUtils.File(PageThumbsStorageService.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. + */ +async function promiseAddVisitsAndRepopulateNewTabLinks(aPlaceInfo) { + await PlacesTestUtils.addVisits(makeURI(aPlaceInfo)); + await new Promise(resolve => { + NewTabUtils.links.populateCache(resolve, true); + }); +} + +/** + * Resolves a Promise 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. + * @returns Promise + */ +function whenFileExists(aURL) { + return TestUtils.waitForCondition( + () => { + return thumbnailExists(aURL); + }, + `Waiting for ${aURL} to exist.`, + 1000, + 50 + ); +} + +/** + * Resolves a Promise when the given file has been removed. + * Keeps trying until the file is removed. + * + * @param aFile The file that is being removed + * @returns Promise + */ +function whenFileRemoved(aFile) { + return TestUtils.waitForCondition( + () => { + return !aFile.exists(); + }, + `Waiting for ${aFile.leafName} to not exist.`, + 1000, + 50 + ); +} + +/** + * 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) { + return bgCaptureWithMethod("capture", aURL, aOptions); +} + +function bgCaptureIfMissing(aURL, aOptions) { + return bgCaptureWithMethod("captureIfMissing", aURL, aOptions); +} + +/** + * Queues a BackgroundPageThumbs capture with the supplied method. + * + * @param {String} aMethodName One of the method names on BackgroundPageThumbs + * for capturing thumbnails. Example: "capture", "captureIfMissing". + * @param {String} aURL The URL of the page to capture. + * @param {Object} aOptions The options object to pass to BackgroundPageThumbs. + * + * @returns {Promise} + * @resolves {Array} Resolves once the capture has completed with an Array of + * results. The first element of the Array is the URL of the captured page, + * and the second element is the completion reason from the BackgroundPageThumbs + * module. + */ +function bgCaptureWithMethod(aMethodName, aURL, aOptions = {}) { + // We'll get oranges if the expiration filter removes the file during the + // test. + dontExpireThumbnailURLs([aURL]); + + return new Promise(resolve => { + let wrappedDoneFn = aOptions.onDone; + aOptions.onDone = (url, doneReason) => { + if (wrappedDoneFn) { + wrappedDoneFn(url, doneReason); + } + resolve([url, doneReason]); + }; + + 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) { + 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"); + Services.obs.addObserver(observe, "page-thumbnail:error"); + }); +} 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 0000000000..576b27f1f3 --- /dev/null +++ b/toolkit/components/thumbnails/test/privacy_cache_control.sjs @@ -0,0 +1,17 @@ +/* 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/sample_image_blue_300x600.jpg b/toolkit/components/thumbnails/test/sample_image_blue_300x600.jpg Binary files differnew file mode 100644 index 0000000000..ad5b44ecbc --- /dev/null +++ b/toolkit/components/thumbnails/test/sample_image_blue_300x600.jpg diff --git a/toolkit/components/thumbnails/test/sample_image_green_1024x1024.jpg b/toolkit/components/thumbnails/test/sample_image_green_1024x1024.jpg Binary files differnew file mode 100644 index 0000000000..7d91079ecd --- /dev/null +++ b/toolkit/components/thumbnails/test/sample_image_green_1024x1024.jpg diff --git a/toolkit/components/thumbnails/test/sample_image_red_1920x1080.jpg b/toolkit/components/thumbnails/test/sample_image_red_1920x1080.jpg Binary files differnew file mode 100644 index 0000000000..7a2bec4d5e --- /dev/null +++ b/toolkit/components/thumbnails/test/sample_image_red_1920x1080.jpg 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 0000000000..394b8591a8 --- /dev/null +++ b/toolkit/components/thumbnails/test/test_thumbnails_interfaces.js @@ -0,0 +1,52 @@ +"use strict"; + +// This is an xpcshell test and gets a browser test env applied, so we +// need to still manually import NetUtil. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); + +// need profile so that PageThumbsStorageService can resolve the path to the underlying file +do_get_profile(); + +function run_test() { + // check the protocol handler implements the correct interface + let handler = Services.io.getProtocolHandler("moz-page-thumb"); + ok( + handler instanceof Ci.nsIProtocolHandler, + "moz-page-thumb handler provides a protocol handler interface" + ); + + // create a dummy loadinfo which we can hand to newChannel. + let dummyURI = Services.io.newURI("https://www.example.com/1"); + let dummyChannel = NetUtil.newChannel({ + uri: dummyURI, + loadUsingSystemPrincipal: true, + }); + let dummyLoadInfo = dummyChannel.loadInfo; + + // and check that the error cases work as specified + let badhost = Services.io.newURI( + "moz-page-thumb://wronghost/?url=http%3A%2F%2Fwww.mozilla.org%2F" + ); + Assert.throws( + () => handler.newChannel(badhost, dummyLoadInfo), + /NS_ERROR_NOT_AVAILABLE/i, + "moz-page-thumb object with wrong host must not resolve to a file path" + ); + + let badQuery = Services.io.newURI( + "moz-page-thumb://thumbnail/http%3A%2F%2Fwww.mozilla.org%2F" + ); + Assert.throws( + () => handler.newChannel(badQuery, dummyLoadInfo), + /NS_ERROR_NOT_AVAILABLE/i, + "moz-page-thumb object with malformed query parameters must not resolve to a file path" + ); + + let noURL = Services.io.newURI("moz-page-thumb://thumbnail/?badStuff"); + Assert.throws( + () => handler.newChannel(noURL, dummyLoadInfo), + /NS_ERROR_NOT_AVAILABLE/i, + "moz-page-thumb object without a URL parameter 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 0000000000..f0d00e82da --- /dev/null +++ b/toolkit/components/thumbnails/test/thumbnails_background.sjs @@ -0,0 +1,91 @@ +/* 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(";").includes("red") + ) { + resp.write( + '<html style="background: #f00;">' + setCookieScript + "</html>" + ); + resp.finish(); + return; + } + + if ( + req.hasHeader("Cookie") && + req.getHeader("Cookie").split(";").includes("green") + ) { + 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 = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + function ding() { + resp.write("OK!"); + resp.finish(); + }, + opts.wait, + Ci.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_update.sjs b/toolkit/components/thumbnails/test/thumbnails_update.sjs new file mode 100644 index 0000000000..78716adff9 --- /dev/null +++ b/toolkit/components/thumbnails/test/thumbnails_update.sjs @@ -0,0 +1,57 @@ +/* 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 0000000000..de47db2398 --- /dev/null +++ b/toolkit/components/thumbnails/test/xpcshell.ini @@ -0,0 +1,5 @@ +[DEFAULT] +head = + +[test_thumbnails_interfaces.js] +skip-if = os == 'android' # xpcom interface not packaged |