/* 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) => { 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"); browser.setAttribute("manualactiveness", "true"); 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); } }, () => { // 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); }