/* 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 { 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); const global = this; // contains base64 version of a placeholder thumbnail #include blankthumb.inc 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; }), /** * 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) return; let browser = this._parentWin.document.createElementNS(XUL_NS, "browser"); browser.setAttribute("type", "content"); browser.setAttribute("disableglobalhistory", "true"); if (Services.prefs.getPrefType("browser.tabs.remote") == Services.prefs.PREF_BOOL && Services.prefs.getBoolPref("browser.tabs.remote")) { browser.setAttribute("remote", "true"); } // 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, }; 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; if (this.options && typeof(this.options.timeout) == "number") { timeout = this.options.timeout; } else { 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 (!data) { // If this background attempt failed, cause a placeholder file to be saved, so // that gets loaded instead of attempting again (and again). PageThumbs._store(this.url, this.url, atob(BLANKTHUMB), true) .then(done, 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); }