diff options
Diffstat (limited to 'toolkit/components/thumbnails/BackgroundPageThumbs.jsm')
-rw-r--r-- | toolkit/components/thumbnails/BackgroundPageThumbs.jsm | 495 |
1 files changed, 495 insertions, 0 deletions
diff --git a/toolkit/components/thumbnails/BackgroundPageThumbs.jsm b/toolkit/components/thumbnails/BackgroundPageThumbs.jsm new file mode 100644 index 000000000..fded51cea --- /dev/null +++ b/toolkit/components/thumbnails/BackgroundPageThumbs.jsm @@ -0,0 +1,495 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = [ + "BackgroundPageThumbs", +]; + +const DEFAULT_CAPTURE_TIMEOUT = 30000; // ms +const DESTROY_BROWSER_TIMEOUT = 60000; // ms +const FRAME_SCRIPT_URL = "chrome://global/content/backgroundPageThumbsContent.js"; + +const TELEMETRY_HISTOGRAM_ID_PREFIX = "FX_THUMBNAILS_BG_"; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +const ABOUT_NEWTAB_SEGREGATION_PREF = "privacy.usercontext.about_newtab_segregation.enabled"; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); +Cu.import("resource://gre/modules/PageThumbs.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +// possible FX_THUMBNAILS_BG_CAPTURE_DONE_REASON_2 telemetry values +const TEL_CAPTURE_DONE_OK = 0; +const TEL_CAPTURE_DONE_TIMEOUT = 1; +// 2 and 3 were used when we had special handling for private-browsing. +const TEL_CAPTURE_DONE_CRASHED = 4; +const TEL_CAPTURE_DONE_BAD_URI = 5; + +// These are looked up on the global as properties below. +XPCOMUtils.defineConstant(this, "TEL_CAPTURE_DONE_OK", TEL_CAPTURE_DONE_OK); +XPCOMUtils.defineConstant(this, "TEL_CAPTURE_DONE_TIMEOUT", TEL_CAPTURE_DONE_TIMEOUT); +XPCOMUtils.defineConstant(this, "TEL_CAPTURE_DONE_CRASHED", TEL_CAPTURE_DONE_CRASHED); +XPCOMUtils.defineConstant(this, "TEL_CAPTURE_DONE_BAD_URI", TEL_CAPTURE_DONE_BAD_URI); + +XPCOMUtils.defineLazyModuleGetter(this, "ContextualIdentityService", + "resource://gre/modules/ContextualIdentityService.jsm"); +const global = this; + +const BackgroundPageThumbs = { + + /** + * Asynchronously captures a thumbnail of the given URL. + * + * The page is loaded anonymously, and plug-ins are disabled. + * + * @param url The URL to capture. + * @param options An optional object that configures the capture. Its + * properties are the following, and all are optional: + * @opt onDone A function that will be asynchronously called when the + * capture is complete or times out. It's called as + * onDone(url), + * where `url` is the captured URL. + * @opt timeout The capture will time out after this many milliseconds have + * elapsed after the capture has progressed to the head of + * the queue and started. Defaults to 30000 (30 seconds). + */ + capture: function (url, options={}) { + if (!PageThumbs._prefEnabled()) { + if (options.onDone) + schedule(() => options.onDone(url)); + return; + } + this._captureQueue = this._captureQueue || []; + this._capturesByURL = this._capturesByURL || new Map(); + + tel("QUEUE_SIZE_ON_CAPTURE", this._captureQueue.length); + + // We want to avoid duplicate captures for the same URL. If there is an + // existing one, we just add the callback to that one and we are done. + let existing = this._capturesByURL.get(url); + if (existing) { + if (options.onDone) + existing.doneCallbacks.push(options.onDone); + // The queue is already being processed, so nothing else to do... + return; + } + let cap = new Capture(url, this._onCaptureOrTimeout.bind(this), options); + this._captureQueue.push(cap); + this._capturesByURL.set(url, cap); + this._processCaptureQueue(); + }, + + /** + * Asynchronously captures a thumbnail of the given URL if one does not + * already exist. Otherwise does nothing. + * + * @param url The URL to capture. + * @param options An optional object that configures the capture. See + * capture() for description. + * @return {Promise} A Promise that resolves when this task completes + */ + captureIfMissing: Task.async(function* (url, options={}) { + // The fileExistsForURL call is an optimization, potentially but unlikely + // incorrect, and no big deal when it is. After the capture is done, we + // atomically test whether the file exists before writing it. + let exists = yield PageThumbsStorage.fileExistsForURL(url); + if (exists) { + if (options.onDone) { + options.onDone(url); + } + return url; + } + let thumbPromise = new Promise((resolve, reject) => { + function observe(subject, topic, data) { // jshint ignore:line + if (data === url) { + switch (topic) { + case "page-thumbnail:create": + resolve(); + break; + case "page-thumbnail:error": + reject(new Error("page-thumbnail:error")); + break; + } + Services.obs.removeObserver(observe, "page-thumbnail:create"); + Services.obs.removeObserver(observe, "page-thumbnail:error"); + } + } + Services.obs.addObserver(observe, "page-thumbnail:create", false); + Services.obs.addObserver(observe, "page-thumbnail:error", false); + }); + try { + this.capture(url, options); + yield thumbPromise; + } catch (err) { + if (options.onDone) { + options.onDone(url); + } + throw err; + } + return url; + }), + + /** + * Tell the service that the thumbnail browser should be recreated at next + * call of _ensureBrowser(). + */ + renewThumbnailBrowser: function() { + this._renewThumbBrowser = true; + }, + + /** + * Ensures that initialization of the thumbnail browser's parent window has + * begun. + * + * @return True if the parent window is completely initialized and can be + * used, and false if initialization has started but not completed. + */ + _ensureParentWindowReady: function () { + if (this._parentWin) + // Already fully initialized. + return true; + if (this._startedParentWinInit) + // Already started initializing. + return false; + + this._startedParentWinInit = true; + + // Create an html:iframe, stick it in the parent document, and + // use it to host the browser. about:blank will not have the system + // principal, so it can't host, but a document with a chrome URI will. + let hostWindow = Services.appShell.hiddenDOMWindow; + let iframe = hostWindow.document.createElementNS(HTML_NS, "iframe"); + iframe.setAttribute("src", "chrome://global/content/mozilla.xhtml"); + let onLoad = function onLoadFn() { + iframe.removeEventListener("load", onLoad, true); + this._parentWin = iframe.contentWindow; + this._processCaptureQueue(); + }.bind(this); + iframe.addEventListener("load", onLoad, true); + hostWindow.document.documentElement.appendChild(iframe); + this._hostIframe = iframe; + + return false; + }, + + /** + * Destroys the service. Queued and pending captures will never complete, and + * their consumer callbacks will never be called. + */ + _destroy: function () { + if (this._captureQueue) + this._captureQueue.forEach(cap => cap.destroy()); + this._destroyBrowser(); + if (this._hostIframe) + this._hostIframe.remove(); + delete this._captureQueue; + delete this._hostIframe; + delete this._startedParentWinInit; + delete this._parentWin; + }, + + /** + * Creates the thumbnail browser if it doesn't already exist. + */ + _ensureBrowser: function () { + if (this._thumbBrowser && !this._renewThumbBrowser) + return; + + this._destroyBrowser(); + this._renewThumbBrowser = false; + + let browser = this._parentWin.document.createElementNS(XUL_NS, "browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("remote", "true"); + browser.setAttribute("disableglobalhistory", "true"); + + if (Services.prefs.getBoolPref(ABOUT_NEWTAB_SEGREGATION_PREF)) { + // Use the private container for thumbnails. + let privateIdentity = + ContextualIdentityService.getPrivateIdentity("userContextIdInternal.thumbnail"); + browser.setAttribute("usercontextid", privateIdentity.userContextId); + } + + // Size the browser. Make its aspect ratio the same as the canvases' that + // the thumbnails are drawn into; the canvases' aspect ratio is the same as + // the screen's, so use that. Aim for a size in the ballpark of 1024x768. + let [swidth, sheight] = [{}, {}]; + Cc["@mozilla.org/gfx/screenmanager;1"]. + getService(Ci.nsIScreenManager). + primaryScreen. + GetRectDisplayPix({}, {}, swidth, sheight); + let bwidth = Math.min(1024, swidth.value); + // Setting the width and height attributes doesn't work -- the resulting + // thumbnails are blank and transparent -- but setting the style does. + browser.style.width = bwidth + "px"; + browser.style.height = (bwidth * sheight.value / swidth.value) + "px"; + + this._parentWin.document.documentElement.appendChild(browser); + + // an event that is sent if the remote process crashes - no need to remove + // it as we want it to be there as long as the browser itself lives. + browser.addEventListener("oop-browser-crashed", () => { + Cu.reportError("BackgroundThumbnails remote process crashed - recovering"); + this._destroyBrowser(); + let curCapture = this._captureQueue.length ? this._captureQueue[0] : null; + // we could retry the pending capture, but it's possible the crash + // was due directly to it, so trying again might just crash again. + // We could keep a flag to indicate if it previously crashed, but + // "resetting" the capture requires more work - so for now, we just + // discard it. + if (curCapture && curCapture.pending) { + // Continue queue processing by calling curCapture._done(). Do it after + // this crashed listener returns, though. A new browser will be created + // immediately (on the same stack as the _done call stack) if there are + // any more queued-up captures, and that seems to mess up the new + // browser's message manager if it happens on the same stack as the + // listener. Trying to send a message to the manager in that case + // throws NS_ERROR_NOT_INITIALIZED. + Services.tm.currentThread.dispatch(() => { + curCapture._done(null, TEL_CAPTURE_DONE_CRASHED); + }, Ci.nsIEventTarget.DISPATCH_NORMAL); + } + // else: we must have been idle and not currently doing a capture (eg, + // maybe a GC or similar crashed) - so there's no need to attempt a + // queue restart - the next capture request will set everything up. + }); + + browser.messageManager.loadFrameScript(FRAME_SCRIPT_URL, false); + this._thumbBrowser = browser; + }, + + _destroyBrowser: function () { + if (!this._thumbBrowser) + return; + this._thumbBrowser.remove(); + delete this._thumbBrowser; + }, + + /** + * Starts the next capture if the queue is not empty and the service is fully + * initialized. + */ + _processCaptureQueue: function () { + if (!this._captureQueue.length || + this._captureQueue[0].pending || + !this._ensureParentWindowReady()) + return; + + // Ready to start the first capture in the queue. + this._ensureBrowser(); + this._captureQueue[0].start(this._thumbBrowser.messageManager); + if (this._destroyBrowserTimer) { + this._destroyBrowserTimer.cancel(); + delete this._destroyBrowserTimer; + } + }, + + /** + * Called when the current capture completes or fails (eg, times out, remote + * process crashes.) + */ + _onCaptureOrTimeout: function (capture) { + // Since timeouts start as an item is being processed, only the first + // item in the queue can be passed to this method. + if (capture !== this._captureQueue[0]) + throw new Error("The capture should be at the head of the queue."); + this._captureQueue.shift(); + this._capturesByURL.delete(capture.url); + if (capture.doneReason != TEL_CAPTURE_DONE_OK) { + Services.obs.notifyObservers(null, "page-thumbnail:error", capture.url); + } + + // Start the destroy-browser timer *before* processing the capture queue. + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this._destroyBrowser.bind(this), + this._destroyBrowserTimeout, + Ci.nsITimer.TYPE_ONE_SHOT); + this._destroyBrowserTimer = timer; + + this._processCaptureQueue(); + }, + + _destroyBrowserTimeout: DESTROY_BROWSER_TIMEOUT, +}; + +Services.prefs.addObserver(ABOUT_NEWTAB_SEGREGATION_PREF, + function(aSubject, aTopic, aData) { + if (aTopic == "nsPref:changed" && aData == ABOUT_NEWTAB_SEGREGATION_PREF) { + BackgroundPageThumbs.renewThumbnailBrowser(); + } + }, + false); + +Object.defineProperty(this, "BackgroundPageThumbs", { + value: BackgroundPageThumbs, + enumerable: true, + writable: false +}); + +/** + * Represents a single capture request in the capture queue. + * + * @param url The URL to capture. + * @param captureCallback A function you want called when the capture + * completes. + * @param options The capture options. + */ +function Capture(url, captureCallback, options) { + this.url = url; + this.captureCallback = captureCallback; + this.options = options; + this.id = Capture.nextID++; + this.creationDate = new Date(); + this.doneCallbacks = []; + this.doneReason; + if (options.onDone) + this.doneCallbacks.push(options.onDone); +} + +Capture.prototype = { + + get pending() { + return !!this._msgMan; + }, + + /** + * Sends a message to the content script to start the capture. + * + * @param messageManager The nsIMessageSender of the thumbnail browser. + */ + start: function (messageManager) { + this.startDate = new Date(); + tel("CAPTURE_QUEUE_TIME_MS", this.startDate - this.creationDate); + + // timeout timer + let timeout = typeof(this.options.timeout) == "number" ? + this.options.timeout : + DEFAULT_CAPTURE_TIMEOUT; + this._timeoutTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this._timeoutTimer.initWithCallback(this, timeout, + Ci.nsITimer.TYPE_ONE_SHOT); + + // didCapture registration + this._msgMan = messageManager; + this._msgMan.sendAsyncMessage("BackgroundPageThumbs:capture", + { id: this.id, url: this.url }); + this._msgMan.addMessageListener("BackgroundPageThumbs:didCapture", this); + }, + + /** + * The only intended external use of this method is by the service when it's + * uninitializing and doing things like destroying the thumbnail browser. In + * that case the consumer's completion callback will never be called. + */ + destroy: function () { + // This method may be called for captures that haven't started yet, so + // guard against not yet having _timeoutTimer, _msgMan etc properties... + if (this._timeoutTimer) { + this._timeoutTimer.cancel(); + delete this._timeoutTimer; + } + if (this._msgMan) { + this._msgMan.removeMessageListener("BackgroundPageThumbs:didCapture", + this); + delete this._msgMan; + } + delete this.captureCallback; + delete this.doneCallbacks; + delete this.options; + }, + + // Called when the didCapture message is received. + receiveMessage: function (msg) { + if (msg.data.imageData) + tel("CAPTURE_SERVICE_TIME_MS", new Date() - this.startDate); + + // A different timed-out capture may have finally successfully completed, so + // discard messages that aren't meant for this capture. + if (msg.data.id != this.id) + return; + + if (msg.data.failReason) { + let reason = global["TEL_CAPTURE_DONE_" + msg.data.failReason]; + this._done(null, reason); + return; + } + + this._done(msg.data, TEL_CAPTURE_DONE_OK); + }, + + // Called when the timeout timer fires. + notify: function () { + this._done(null, TEL_CAPTURE_DONE_TIMEOUT); + }, + + _done: function (data, reason) { + // Note that _done will be called only once, by either receiveMessage or + // notify, since it calls destroy here, which cancels the timeout timer and + // removes the didCapture message listener. + let { captureCallback, doneCallbacks, options } = this; + this.destroy(); + this.doneReason = reason; + + if (typeof(reason) != "number") { + throw new Error("A done reason must be given."); + } + tel("CAPTURE_DONE_REASON_2", reason); + if (data && data.telemetry) { + // Telemetry is currently disabled in the content process (bug 680508). + for (let id in data.telemetry) { + tel(id, data.telemetry[id]); + } + } + + let done = () => { + captureCallback(this); + for (let callback of doneCallbacks) { + try { + callback.call(options, this.url); + } + catch (err) { + Cu.reportError(err); + } + } + + if (Services.prefs.getBoolPref(ABOUT_NEWTAB_SEGREGATION_PREF)) { + // Clear the data in the private container for thumbnails. + let privateIdentity = + ContextualIdentityService.getPrivateIdentity("userContextIdInternal.thumbnail"); + Services.obs.notifyObservers(null, "clear-origin-attributes-data", + JSON.stringify({ userContextId: privateIdentity.userContextId })); + } + }; + + if (!data) { + done(); + return; + } + + PageThumbs._store(this.url, data.finalURL, data.imageData, true) + .then(done, done); + }, +}; + +Capture.nextID = 0; + +/** + * Adds a value to one of this module's telemetry histograms. + * + * @param histogramID This is prefixed with this module's ID. + * @param value The value to add. + */ +function tel(histogramID, value) { + let id = TELEMETRY_HISTOGRAM_ID_PREFIX + histogramID; + Services.telemetry.getHistogramById(id).add(value); +} + +function schedule(callback) { + Services.tm.mainThread.dispatch(callback, Ci.nsIThread.DISPATCH_NORMAL); +} |