1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/toolkit/components/thumbnails/BackgroundPageThumbs.jsm Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,391 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +const EXPORTED_SYMBOLS = [ 1.9 + "BackgroundPageThumbs", 1.10 +]; 1.11 + 1.12 +const DEFAULT_CAPTURE_TIMEOUT = 30000; // ms 1.13 +const DESTROY_BROWSER_TIMEOUT = 60000; // ms 1.14 +const FRAME_SCRIPT_URL = "chrome://global/content/backgroundPageThumbsContent.js"; 1.15 + 1.16 +const TELEMETRY_HISTOGRAM_ID_PREFIX = "FX_THUMBNAILS_BG_"; 1.17 + 1.18 +// possible FX_THUMBNAILS_BG_CAPTURE_DONE_REASON_2 telemetry values 1.19 +const TEL_CAPTURE_DONE_OK = 0; 1.20 +const TEL_CAPTURE_DONE_TIMEOUT = 1; 1.21 +// 2 and 3 were used when we had special handling for private-browsing. 1.22 +const TEL_CAPTURE_DONE_CRASHED = 4; 1.23 + 1.24 +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; 1.25 +const HTML_NS = "http://www.w3.org/1999/xhtml"; 1.26 + 1.27 +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; 1.28 + 1.29 +Cu.import("resource://gre/modules/PageThumbs.jsm"); 1.30 +Cu.import("resource://gre/modules/Services.jsm"); 1.31 + 1.32 +const BackgroundPageThumbs = { 1.33 + 1.34 + /** 1.35 + * Asynchronously captures a thumbnail of the given URL. 1.36 + * 1.37 + * The page is loaded anonymously, and plug-ins are disabled. 1.38 + * 1.39 + * @param url The URL to capture. 1.40 + * @param options An optional object that configures the capture. Its 1.41 + * properties are the following, and all are optional: 1.42 + * @opt onDone A function that will be asynchronously called when the 1.43 + * capture is complete or times out. It's called as 1.44 + * onDone(url), 1.45 + * where `url` is the captured URL. 1.46 + * @opt timeout The capture will time out after this many milliseconds have 1.47 + * elapsed after the capture has progressed to the head of 1.48 + * the queue and started. Defaults to 30000 (30 seconds). 1.49 + */ 1.50 + capture: function (url, options={}) { 1.51 + if (!PageThumbs._prefEnabled()) { 1.52 + if (options.onDone) 1.53 + schedule(() => options.onDone(url)); 1.54 + return; 1.55 + } 1.56 + this._captureQueue = this._captureQueue || []; 1.57 + this._capturesByURL = this._capturesByURL || new Map(); 1.58 + 1.59 + tel("QUEUE_SIZE_ON_CAPTURE", this._captureQueue.length); 1.60 + 1.61 + // We want to avoid duplicate captures for the same URL. If there is an 1.62 + // existing one, we just add the callback to that one and we are done. 1.63 + let existing = this._capturesByURL.get(url); 1.64 + if (existing) { 1.65 + if (options.onDone) 1.66 + existing.doneCallbacks.push(options.onDone); 1.67 + // The queue is already being processed, so nothing else to do... 1.68 + return; 1.69 + } 1.70 + let cap = new Capture(url, this._onCaptureOrTimeout.bind(this), options); 1.71 + this._captureQueue.push(cap); 1.72 + this._capturesByURL.set(url, cap); 1.73 + this._processCaptureQueue(); 1.74 + }, 1.75 + 1.76 + /** 1.77 + * Asynchronously captures a thumbnail of the given URL if one does not 1.78 + * already exist. Otherwise does nothing. 1.79 + * 1.80 + * @param url The URL to capture. 1.81 + * @param options An optional object that configures the capture. See 1.82 + * capture() for description. 1.83 + */ 1.84 + captureIfMissing: function (url, options={}) { 1.85 + // The fileExistsForURL call is an optimization, potentially but unlikely 1.86 + // incorrect, and no big deal when it is. After the capture is done, we 1.87 + // atomically test whether the file exists before writing it. 1.88 + PageThumbsStorage.fileExistsForURL(url).then(exists => { 1.89 + if (exists.ok) { 1.90 + if (options.onDone) 1.91 + options.onDone(url); 1.92 + return; 1.93 + } 1.94 + this.capture(url, options); 1.95 + }, err => { 1.96 + if (options.onDone) 1.97 + options.onDone(url); 1.98 + }); 1.99 + }, 1.100 + 1.101 + /** 1.102 + * Ensures that initialization of the thumbnail browser's parent window has 1.103 + * begun. 1.104 + * 1.105 + * @return True if the parent window is completely initialized and can be 1.106 + * used, and false if initialization has started but not completed. 1.107 + */ 1.108 + _ensureParentWindowReady: function () { 1.109 + if (this._parentWin) 1.110 + // Already fully initialized. 1.111 + return true; 1.112 + if (this._startedParentWinInit) 1.113 + // Already started initializing. 1.114 + return false; 1.115 + 1.116 + this._startedParentWinInit = true; 1.117 + 1.118 + // Create an html:iframe, stick it in the parent document, and 1.119 + // use it to host the browser. about:blank will not have the system 1.120 + // principal, so it can't host, but a document with a chrome URI will. 1.121 + let hostWindow = Services.appShell.hiddenDOMWindow; 1.122 + let iframe = hostWindow.document.createElementNS(HTML_NS, "iframe"); 1.123 + iframe.setAttribute("src", "chrome://global/content/mozilla.xhtml"); 1.124 + let onLoad = function onLoadFn() { 1.125 + iframe.removeEventListener("load", onLoad, true); 1.126 + this._parentWin = iframe.contentWindow; 1.127 + this._processCaptureQueue(); 1.128 + }.bind(this); 1.129 + iframe.addEventListener("load", onLoad, true); 1.130 + hostWindow.document.documentElement.appendChild(iframe); 1.131 + this._hostIframe = iframe; 1.132 + 1.133 + return false; 1.134 + }, 1.135 + 1.136 + /** 1.137 + * Destroys the service. Queued and pending captures will never complete, and 1.138 + * their consumer callbacks will never be called. 1.139 + */ 1.140 + _destroy: function () { 1.141 + if (this._captureQueue) 1.142 + this._captureQueue.forEach(cap => cap.destroy()); 1.143 + this._destroyBrowser(); 1.144 + if (this._hostIframe) 1.145 + this._hostIframe.remove(); 1.146 + delete this._captureQueue; 1.147 + delete this._hostIframe; 1.148 + delete this._startedParentWinInit; 1.149 + delete this._parentWin; 1.150 + }, 1.151 + 1.152 + /** 1.153 + * Creates the thumbnail browser if it doesn't already exist. 1.154 + */ 1.155 + _ensureBrowser: function () { 1.156 + if (this._thumbBrowser) 1.157 + return; 1.158 + 1.159 + let browser = this._parentWin.document.createElementNS(XUL_NS, "browser"); 1.160 + browser.setAttribute("type", "content"); 1.161 + browser.setAttribute("remote", "true"); 1.162 + 1.163 + // Size the browser. Make its aspect ratio the same as the canvases' that 1.164 + // the thumbnails are drawn into; the canvases' aspect ratio is the same as 1.165 + // the screen's, so use that. Aim for a size in the ballpark of 1024x768. 1.166 + let [swidth, sheight] = [{}, {}]; 1.167 + Cc["@mozilla.org/gfx/screenmanager;1"]. 1.168 + getService(Ci.nsIScreenManager). 1.169 + primaryScreen. 1.170 + GetRectDisplayPix({}, {}, swidth, sheight); 1.171 + let bwidth = Math.min(1024, swidth.value); 1.172 + // Setting the width and height attributes doesn't work -- the resulting 1.173 + // thumbnails are blank and transparent -- but setting the style does. 1.174 + browser.style.width = bwidth + "px"; 1.175 + browser.style.height = (bwidth * sheight.value / swidth.value) + "px"; 1.176 + 1.177 + this._parentWin.document.documentElement.appendChild(browser); 1.178 + 1.179 + // an event that is sent if the remote process crashes - no need to remove 1.180 + // it as we want it to be there as long as the browser itself lives. 1.181 + browser.addEventListener("oop-browser-crashed", () => { 1.182 + Cu.reportError("BackgroundThumbnails remote process crashed - recovering"); 1.183 + this._destroyBrowser(); 1.184 + let curCapture = this._captureQueue.length ? this._captureQueue[0] : null; 1.185 + // we could retry the pending capture, but it's possible the crash 1.186 + // was due directly to it, so trying again might just crash again. 1.187 + // We could keep a flag to indicate if it previously crashed, but 1.188 + // "resetting" the capture requires more work - so for now, we just 1.189 + // discard it. 1.190 + if (curCapture && curCapture.pending) { 1.191 + curCapture._done(null, TEL_CAPTURE_DONE_CRASHED); 1.192 + // _done automatically continues queue processing. 1.193 + } 1.194 + // else: we must have been idle and not currently doing a capture (eg, 1.195 + // maybe a GC or similar crashed) - so there's no need to attempt a 1.196 + // queue restart - the next capture request will set everything up. 1.197 + }); 1.198 + 1.199 + browser.messageManager.loadFrameScript(FRAME_SCRIPT_URL, false); 1.200 + this._thumbBrowser = browser; 1.201 + }, 1.202 + 1.203 + _destroyBrowser: function () { 1.204 + if (!this._thumbBrowser) 1.205 + return; 1.206 + this._thumbBrowser.remove(); 1.207 + delete this._thumbBrowser; 1.208 + }, 1.209 + 1.210 + /** 1.211 + * Starts the next capture if the queue is not empty and the service is fully 1.212 + * initialized. 1.213 + */ 1.214 + _processCaptureQueue: function () { 1.215 + if (!this._captureQueue.length || 1.216 + this._captureQueue[0].pending || 1.217 + !this._ensureParentWindowReady()) 1.218 + return; 1.219 + 1.220 + // Ready to start the first capture in the queue. 1.221 + this._ensureBrowser(); 1.222 + this._captureQueue[0].start(this._thumbBrowser.messageManager); 1.223 + if (this._destroyBrowserTimer) { 1.224 + this._destroyBrowserTimer.cancel(); 1.225 + delete this._destroyBrowserTimer; 1.226 + } 1.227 + }, 1.228 + 1.229 + /** 1.230 + * Called when the current capture completes or fails (eg, times out, remote 1.231 + * process crashes.) 1.232 + */ 1.233 + _onCaptureOrTimeout: function (capture) { 1.234 + // Since timeouts start as an item is being processed, only the first 1.235 + // item in the queue can be passed to this method. 1.236 + if (capture !== this._captureQueue[0]) 1.237 + throw new Error("The capture should be at the head of the queue."); 1.238 + this._captureQueue.shift(); 1.239 + this._capturesByURL.delete(capture.url); 1.240 + 1.241 + // Start the destroy-browser timer *before* processing the capture queue. 1.242 + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 1.243 + timer.initWithCallback(this._destroyBrowser.bind(this), 1.244 + this._destroyBrowserTimeout, 1.245 + Ci.nsITimer.TYPE_ONE_SHOT); 1.246 + this._destroyBrowserTimer = timer; 1.247 + 1.248 + this._processCaptureQueue(); 1.249 + }, 1.250 + 1.251 + _destroyBrowserTimeout: DESTROY_BROWSER_TIMEOUT, 1.252 +}; 1.253 + 1.254 +/** 1.255 + * Represents a single capture request in the capture queue. 1.256 + * 1.257 + * @param url The URL to capture. 1.258 + * @param captureCallback A function you want called when the capture 1.259 + * completes. 1.260 + * @param options The capture options. 1.261 + */ 1.262 +function Capture(url, captureCallback, options) { 1.263 + this.url = url; 1.264 + this.captureCallback = captureCallback; 1.265 + this.options = options; 1.266 + this.id = Capture.nextID++; 1.267 + this.creationDate = new Date(); 1.268 + this.doneCallbacks = []; 1.269 + if (options.onDone) 1.270 + this.doneCallbacks.push(options.onDone); 1.271 +} 1.272 + 1.273 +Capture.prototype = { 1.274 + 1.275 + get pending() { 1.276 + return !!this._msgMan; 1.277 + }, 1.278 + 1.279 + /** 1.280 + * Sends a message to the content script to start the capture. 1.281 + * 1.282 + * @param messageManager The nsIMessageSender of the thumbnail browser. 1.283 + */ 1.284 + start: function (messageManager) { 1.285 + this.startDate = new Date(); 1.286 + tel("CAPTURE_QUEUE_TIME_MS", this.startDate - this.creationDate); 1.287 + 1.288 + // timeout timer 1.289 + let timeout = typeof(this.options.timeout) == "number" ? 1.290 + this.options.timeout : 1.291 + DEFAULT_CAPTURE_TIMEOUT; 1.292 + this._timeoutTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 1.293 + this._timeoutTimer.initWithCallback(this, timeout, 1.294 + Ci.nsITimer.TYPE_ONE_SHOT); 1.295 + 1.296 + // didCapture registration 1.297 + this._msgMan = messageManager; 1.298 + this._msgMan.sendAsyncMessage("BackgroundPageThumbs:capture", 1.299 + { id: this.id, url: this.url }); 1.300 + this._msgMan.addMessageListener("BackgroundPageThumbs:didCapture", this); 1.301 + }, 1.302 + 1.303 + /** 1.304 + * The only intended external use of this method is by the service when it's 1.305 + * uninitializing and doing things like destroying the thumbnail browser. In 1.306 + * that case the consumer's completion callback will never be called. 1.307 + */ 1.308 + destroy: function () { 1.309 + // This method may be called for captures that haven't started yet, so 1.310 + // guard against not yet having _timeoutTimer, _msgMan etc properties... 1.311 + if (this._timeoutTimer) { 1.312 + this._timeoutTimer.cancel(); 1.313 + delete this._timeoutTimer; 1.314 + } 1.315 + if (this._msgMan) { 1.316 + this._msgMan.removeMessageListener("BackgroundPageThumbs:didCapture", 1.317 + this); 1.318 + delete this._msgMan; 1.319 + } 1.320 + delete this.captureCallback; 1.321 + delete this.doneCallbacks; 1.322 + delete this.options; 1.323 + }, 1.324 + 1.325 + // Called when the didCapture message is received. 1.326 + receiveMessage: function (msg) { 1.327 + tel("CAPTURE_SERVICE_TIME_MS", new Date() - this.startDate); 1.328 + 1.329 + // A different timed-out capture may have finally successfully completed, so 1.330 + // discard messages that aren't meant for this capture. 1.331 + if (msg.json.id == this.id) 1.332 + this._done(msg.json, TEL_CAPTURE_DONE_OK); 1.333 + }, 1.334 + 1.335 + // Called when the timeout timer fires. 1.336 + notify: function () { 1.337 + this._done(null, TEL_CAPTURE_DONE_TIMEOUT); 1.338 + }, 1.339 + 1.340 + _done: function (data, reason) { 1.341 + // Note that _done will be called only once, by either receiveMessage or 1.342 + // notify, since it calls destroy here, which cancels the timeout timer and 1.343 + // removes the didCapture message listener. 1.344 + let { captureCallback, doneCallbacks, options } = this; 1.345 + this.destroy(); 1.346 + 1.347 + if (typeof(reason) != "number") 1.348 + throw new Error("A done reason must be given."); 1.349 + tel("CAPTURE_DONE_REASON_2", reason); 1.350 + if (data && data.telemetry) { 1.351 + // Telemetry is currently disabled in the content process (bug 680508). 1.352 + for (let id in data.telemetry) { 1.353 + tel(id, data.telemetry[id]); 1.354 + } 1.355 + } 1.356 + 1.357 + let done = () => { 1.358 + captureCallback(this); 1.359 + for (let callback of doneCallbacks) { 1.360 + try { 1.361 + callback.call(options, this.url); 1.362 + } 1.363 + catch (err) { 1.364 + Cu.reportError(err); 1.365 + } 1.366 + } 1.367 + }; 1.368 + 1.369 + if (!data) { 1.370 + done(); 1.371 + return; 1.372 + } 1.373 + 1.374 + PageThumbs._store(this.url, data.finalURL, data.imageData, true) 1.375 + .then(done, done); 1.376 + }, 1.377 +}; 1.378 + 1.379 +Capture.nextID = 0; 1.380 + 1.381 +/** 1.382 + * Adds a value to one of this module's telemetry histograms. 1.383 + * 1.384 + * @param histogramID This is prefixed with this module's ID. 1.385 + * @param value The value to add. 1.386 + */ 1.387 +function tel(histogramID, value) { 1.388 + let id = TELEMETRY_HISTOGRAM_ID_PREFIX + histogramID; 1.389 + Services.telemetry.getHistogramById(id).add(value); 1.390 +} 1.391 + 1.392 +function schedule(callback) { 1.393 + Services.tm.mainThread.dispatch(callback, Ci.nsIThread.DISPATCH_NORMAL); 1.394 +}