|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 const EXPORTED_SYMBOLS = [ |
|
6 "BackgroundPageThumbs", |
|
7 ]; |
|
8 |
|
9 const DEFAULT_CAPTURE_TIMEOUT = 30000; // ms |
|
10 const DESTROY_BROWSER_TIMEOUT = 60000; // ms |
|
11 const FRAME_SCRIPT_URL = "chrome://global/content/backgroundPageThumbsContent.js"; |
|
12 |
|
13 const TELEMETRY_HISTOGRAM_ID_PREFIX = "FX_THUMBNAILS_BG_"; |
|
14 |
|
15 // possible FX_THUMBNAILS_BG_CAPTURE_DONE_REASON_2 telemetry values |
|
16 const TEL_CAPTURE_DONE_OK = 0; |
|
17 const TEL_CAPTURE_DONE_TIMEOUT = 1; |
|
18 // 2 and 3 were used when we had special handling for private-browsing. |
|
19 const TEL_CAPTURE_DONE_CRASHED = 4; |
|
20 |
|
21 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; |
|
22 const HTML_NS = "http://www.w3.org/1999/xhtml"; |
|
23 |
|
24 const { classes: Cc, interfaces: Ci, utils: Cu } = Components; |
|
25 |
|
26 Cu.import("resource://gre/modules/PageThumbs.jsm"); |
|
27 Cu.import("resource://gre/modules/Services.jsm"); |
|
28 |
|
29 const BackgroundPageThumbs = { |
|
30 |
|
31 /** |
|
32 * Asynchronously captures a thumbnail of the given URL. |
|
33 * |
|
34 * The page is loaded anonymously, and plug-ins are disabled. |
|
35 * |
|
36 * @param url The URL to capture. |
|
37 * @param options An optional object that configures the capture. Its |
|
38 * properties are the following, and all are optional: |
|
39 * @opt onDone A function that will be asynchronously called when the |
|
40 * capture is complete or times out. It's called as |
|
41 * onDone(url), |
|
42 * where `url` is the captured URL. |
|
43 * @opt timeout The capture will time out after this many milliseconds have |
|
44 * elapsed after the capture has progressed to the head of |
|
45 * the queue and started. Defaults to 30000 (30 seconds). |
|
46 */ |
|
47 capture: function (url, options={}) { |
|
48 if (!PageThumbs._prefEnabled()) { |
|
49 if (options.onDone) |
|
50 schedule(() => options.onDone(url)); |
|
51 return; |
|
52 } |
|
53 this._captureQueue = this._captureQueue || []; |
|
54 this._capturesByURL = this._capturesByURL || new Map(); |
|
55 |
|
56 tel("QUEUE_SIZE_ON_CAPTURE", this._captureQueue.length); |
|
57 |
|
58 // We want to avoid duplicate captures for the same URL. If there is an |
|
59 // existing one, we just add the callback to that one and we are done. |
|
60 let existing = this._capturesByURL.get(url); |
|
61 if (existing) { |
|
62 if (options.onDone) |
|
63 existing.doneCallbacks.push(options.onDone); |
|
64 // The queue is already being processed, so nothing else to do... |
|
65 return; |
|
66 } |
|
67 let cap = new Capture(url, this._onCaptureOrTimeout.bind(this), options); |
|
68 this._captureQueue.push(cap); |
|
69 this._capturesByURL.set(url, cap); |
|
70 this._processCaptureQueue(); |
|
71 }, |
|
72 |
|
73 /** |
|
74 * Asynchronously captures a thumbnail of the given URL if one does not |
|
75 * already exist. Otherwise does nothing. |
|
76 * |
|
77 * @param url The URL to capture. |
|
78 * @param options An optional object that configures the capture. See |
|
79 * capture() for description. |
|
80 */ |
|
81 captureIfMissing: function (url, options={}) { |
|
82 // The fileExistsForURL call is an optimization, potentially but unlikely |
|
83 // incorrect, and no big deal when it is. After the capture is done, we |
|
84 // atomically test whether the file exists before writing it. |
|
85 PageThumbsStorage.fileExistsForURL(url).then(exists => { |
|
86 if (exists.ok) { |
|
87 if (options.onDone) |
|
88 options.onDone(url); |
|
89 return; |
|
90 } |
|
91 this.capture(url, options); |
|
92 }, err => { |
|
93 if (options.onDone) |
|
94 options.onDone(url); |
|
95 }); |
|
96 }, |
|
97 |
|
98 /** |
|
99 * Ensures that initialization of the thumbnail browser's parent window has |
|
100 * begun. |
|
101 * |
|
102 * @return True if the parent window is completely initialized and can be |
|
103 * used, and false if initialization has started but not completed. |
|
104 */ |
|
105 _ensureParentWindowReady: function () { |
|
106 if (this._parentWin) |
|
107 // Already fully initialized. |
|
108 return true; |
|
109 if (this._startedParentWinInit) |
|
110 // Already started initializing. |
|
111 return false; |
|
112 |
|
113 this._startedParentWinInit = true; |
|
114 |
|
115 // Create an html:iframe, stick it in the parent document, and |
|
116 // use it to host the browser. about:blank will not have the system |
|
117 // principal, so it can't host, but a document with a chrome URI will. |
|
118 let hostWindow = Services.appShell.hiddenDOMWindow; |
|
119 let iframe = hostWindow.document.createElementNS(HTML_NS, "iframe"); |
|
120 iframe.setAttribute("src", "chrome://global/content/mozilla.xhtml"); |
|
121 let onLoad = function onLoadFn() { |
|
122 iframe.removeEventListener("load", onLoad, true); |
|
123 this._parentWin = iframe.contentWindow; |
|
124 this._processCaptureQueue(); |
|
125 }.bind(this); |
|
126 iframe.addEventListener("load", onLoad, true); |
|
127 hostWindow.document.documentElement.appendChild(iframe); |
|
128 this._hostIframe = iframe; |
|
129 |
|
130 return false; |
|
131 }, |
|
132 |
|
133 /** |
|
134 * Destroys the service. Queued and pending captures will never complete, and |
|
135 * their consumer callbacks will never be called. |
|
136 */ |
|
137 _destroy: function () { |
|
138 if (this._captureQueue) |
|
139 this._captureQueue.forEach(cap => cap.destroy()); |
|
140 this._destroyBrowser(); |
|
141 if (this._hostIframe) |
|
142 this._hostIframe.remove(); |
|
143 delete this._captureQueue; |
|
144 delete this._hostIframe; |
|
145 delete this._startedParentWinInit; |
|
146 delete this._parentWin; |
|
147 }, |
|
148 |
|
149 /** |
|
150 * Creates the thumbnail browser if it doesn't already exist. |
|
151 */ |
|
152 _ensureBrowser: function () { |
|
153 if (this._thumbBrowser) |
|
154 return; |
|
155 |
|
156 let browser = this._parentWin.document.createElementNS(XUL_NS, "browser"); |
|
157 browser.setAttribute("type", "content"); |
|
158 browser.setAttribute("remote", "true"); |
|
159 |
|
160 // Size the browser. Make its aspect ratio the same as the canvases' that |
|
161 // the thumbnails are drawn into; the canvases' aspect ratio is the same as |
|
162 // the screen's, so use that. Aim for a size in the ballpark of 1024x768. |
|
163 let [swidth, sheight] = [{}, {}]; |
|
164 Cc["@mozilla.org/gfx/screenmanager;1"]. |
|
165 getService(Ci.nsIScreenManager). |
|
166 primaryScreen. |
|
167 GetRectDisplayPix({}, {}, swidth, sheight); |
|
168 let bwidth = Math.min(1024, swidth.value); |
|
169 // Setting the width and height attributes doesn't work -- the resulting |
|
170 // thumbnails are blank and transparent -- but setting the style does. |
|
171 browser.style.width = bwidth + "px"; |
|
172 browser.style.height = (bwidth * sheight.value / swidth.value) + "px"; |
|
173 |
|
174 this._parentWin.document.documentElement.appendChild(browser); |
|
175 |
|
176 // an event that is sent if the remote process crashes - no need to remove |
|
177 // it as we want it to be there as long as the browser itself lives. |
|
178 browser.addEventListener("oop-browser-crashed", () => { |
|
179 Cu.reportError("BackgroundThumbnails remote process crashed - recovering"); |
|
180 this._destroyBrowser(); |
|
181 let curCapture = this._captureQueue.length ? this._captureQueue[0] : null; |
|
182 // we could retry the pending capture, but it's possible the crash |
|
183 // was due directly to it, so trying again might just crash again. |
|
184 // We could keep a flag to indicate if it previously crashed, but |
|
185 // "resetting" the capture requires more work - so for now, we just |
|
186 // discard it. |
|
187 if (curCapture && curCapture.pending) { |
|
188 curCapture._done(null, TEL_CAPTURE_DONE_CRASHED); |
|
189 // _done automatically continues queue processing. |
|
190 } |
|
191 // else: we must have been idle and not currently doing a capture (eg, |
|
192 // maybe a GC or similar crashed) - so there's no need to attempt a |
|
193 // queue restart - the next capture request will set everything up. |
|
194 }); |
|
195 |
|
196 browser.messageManager.loadFrameScript(FRAME_SCRIPT_URL, false); |
|
197 this._thumbBrowser = browser; |
|
198 }, |
|
199 |
|
200 _destroyBrowser: function () { |
|
201 if (!this._thumbBrowser) |
|
202 return; |
|
203 this._thumbBrowser.remove(); |
|
204 delete this._thumbBrowser; |
|
205 }, |
|
206 |
|
207 /** |
|
208 * Starts the next capture if the queue is not empty and the service is fully |
|
209 * initialized. |
|
210 */ |
|
211 _processCaptureQueue: function () { |
|
212 if (!this._captureQueue.length || |
|
213 this._captureQueue[0].pending || |
|
214 !this._ensureParentWindowReady()) |
|
215 return; |
|
216 |
|
217 // Ready to start the first capture in the queue. |
|
218 this._ensureBrowser(); |
|
219 this._captureQueue[0].start(this._thumbBrowser.messageManager); |
|
220 if (this._destroyBrowserTimer) { |
|
221 this._destroyBrowserTimer.cancel(); |
|
222 delete this._destroyBrowserTimer; |
|
223 } |
|
224 }, |
|
225 |
|
226 /** |
|
227 * Called when the current capture completes or fails (eg, times out, remote |
|
228 * process crashes.) |
|
229 */ |
|
230 _onCaptureOrTimeout: function (capture) { |
|
231 // Since timeouts start as an item is being processed, only the first |
|
232 // item in the queue can be passed to this method. |
|
233 if (capture !== this._captureQueue[0]) |
|
234 throw new Error("The capture should be at the head of the queue."); |
|
235 this._captureQueue.shift(); |
|
236 this._capturesByURL.delete(capture.url); |
|
237 |
|
238 // Start the destroy-browser timer *before* processing the capture queue. |
|
239 let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); |
|
240 timer.initWithCallback(this._destroyBrowser.bind(this), |
|
241 this._destroyBrowserTimeout, |
|
242 Ci.nsITimer.TYPE_ONE_SHOT); |
|
243 this._destroyBrowserTimer = timer; |
|
244 |
|
245 this._processCaptureQueue(); |
|
246 }, |
|
247 |
|
248 _destroyBrowserTimeout: DESTROY_BROWSER_TIMEOUT, |
|
249 }; |
|
250 |
|
251 /** |
|
252 * Represents a single capture request in the capture queue. |
|
253 * |
|
254 * @param url The URL to capture. |
|
255 * @param captureCallback A function you want called when the capture |
|
256 * completes. |
|
257 * @param options The capture options. |
|
258 */ |
|
259 function Capture(url, captureCallback, options) { |
|
260 this.url = url; |
|
261 this.captureCallback = captureCallback; |
|
262 this.options = options; |
|
263 this.id = Capture.nextID++; |
|
264 this.creationDate = new Date(); |
|
265 this.doneCallbacks = []; |
|
266 if (options.onDone) |
|
267 this.doneCallbacks.push(options.onDone); |
|
268 } |
|
269 |
|
270 Capture.prototype = { |
|
271 |
|
272 get pending() { |
|
273 return !!this._msgMan; |
|
274 }, |
|
275 |
|
276 /** |
|
277 * Sends a message to the content script to start the capture. |
|
278 * |
|
279 * @param messageManager The nsIMessageSender of the thumbnail browser. |
|
280 */ |
|
281 start: function (messageManager) { |
|
282 this.startDate = new Date(); |
|
283 tel("CAPTURE_QUEUE_TIME_MS", this.startDate - this.creationDate); |
|
284 |
|
285 // timeout timer |
|
286 let timeout = typeof(this.options.timeout) == "number" ? |
|
287 this.options.timeout : |
|
288 DEFAULT_CAPTURE_TIMEOUT; |
|
289 this._timeoutTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); |
|
290 this._timeoutTimer.initWithCallback(this, timeout, |
|
291 Ci.nsITimer.TYPE_ONE_SHOT); |
|
292 |
|
293 // didCapture registration |
|
294 this._msgMan = messageManager; |
|
295 this._msgMan.sendAsyncMessage("BackgroundPageThumbs:capture", |
|
296 { id: this.id, url: this.url }); |
|
297 this._msgMan.addMessageListener("BackgroundPageThumbs:didCapture", this); |
|
298 }, |
|
299 |
|
300 /** |
|
301 * The only intended external use of this method is by the service when it's |
|
302 * uninitializing and doing things like destroying the thumbnail browser. In |
|
303 * that case the consumer's completion callback will never be called. |
|
304 */ |
|
305 destroy: function () { |
|
306 // This method may be called for captures that haven't started yet, so |
|
307 // guard against not yet having _timeoutTimer, _msgMan etc properties... |
|
308 if (this._timeoutTimer) { |
|
309 this._timeoutTimer.cancel(); |
|
310 delete this._timeoutTimer; |
|
311 } |
|
312 if (this._msgMan) { |
|
313 this._msgMan.removeMessageListener("BackgroundPageThumbs:didCapture", |
|
314 this); |
|
315 delete this._msgMan; |
|
316 } |
|
317 delete this.captureCallback; |
|
318 delete this.doneCallbacks; |
|
319 delete this.options; |
|
320 }, |
|
321 |
|
322 // Called when the didCapture message is received. |
|
323 receiveMessage: function (msg) { |
|
324 tel("CAPTURE_SERVICE_TIME_MS", new Date() - this.startDate); |
|
325 |
|
326 // A different timed-out capture may have finally successfully completed, so |
|
327 // discard messages that aren't meant for this capture. |
|
328 if (msg.json.id == this.id) |
|
329 this._done(msg.json, TEL_CAPTURE_DONE_OK); |
|
330 }, |
|
331 |
|
332 // Called when the timeout timer fires. |
|
333 notify: function () { |
|
334 this._done(null, TEL_CAPTURE_DONE_TIMEOUT); |
|
335 }, |
|
336 |
|
337 _done: function (data, reason) { |
|
338 // Note that _done will be called only once, by either receiveMessage or |
|
339 // notify, since it calls destroy here, which cancels the timeout timer and |
|
340 // removes the didCapture message listener. |
|
341 let { captureCallback, doneCallbacks, options } = this; |
|
342 this.destroy(); |
|
343 |
|
344 if (typeof(reason) != "number") |
|
345 throw new Error("A done reason must be given."); |
|
346 tel("CAPTURE_DONE_REASON_2", reason); |
|
347 if (data && data.telemetry) { |
|
348 // Telemetry is currently disabled in the content process (bug 680508). |
|
349 for (let id in data.telemetry) { |
|
350 tel(id, data.telemetry[id]); |
|
351 } |
|
352 } |
|
353 |
|
354 let done = () => { |
|
355 captureCallback(this); |
|
356 for (let callback of doneCallbacks) { |
|
357 try { |
|
358 callback.call(options, this.url); |
|
359 } |
|
360 catch (err) { |
|
361 Cu.reportError(err); |
|
362 } |
|
363 } |
|
364 }; |
|
365 |
|
366 if (!data) { |
|
367 done(); |
|
368 return; |
|
369 } |
|
370 |
|
371 PageThumbs._store(this.url, data.finalURL, data.imageData, true) |
|
372 .then(done, done); |
|
373 }, |
|
374 }; |
|
375 |
|
376 Capture.nextID = 0; |
|
377 |
|
378 /** |
|
379 * Adds a value to one of this module's telemetry histograms. |
|
380 * |
|
381 * @param histogramID This is prefixed with this module's ID. |
|
382 * @param value The value to add. |
|
383 */ |
|
384 function tel(histogramID, value) { |
|
385 let id = TELEMETRY_HISTOGRAM_ID_PREFIX + histogramID; |
|
386 Services.telemetry.getHistogramById(id).add(value); |
|
387 } |
|
388 |
|
389 function schedule(callback) { |
|
390 Services.tm.mainThread.dispatch(callback, Ci.nsIThread.DISPATCH_NORMAL); |
|
391 } |