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