Bug 1675375 Part 7: Update expectations in helper_hittest_clippath.html. r=botond
[gecko.git] / browser / modules / FaviconLoader.jsm
blob45c64afb58b59f63542647e0c83dba57a30ca08b
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/. */
5 "use strict";
7 const EXPORTED_SYMBOLS = ["FaviconLoader"];
9 const { XPCOMUtils } = ChromeUtils.import(
10   "resource://gre/modules/XPCOMUtils.jsm"
12 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
14 XPCOMUtils.defineLazyGlobalGetters(this, ["Blob", "FileReader"]);
16 ChromeUtils.defineModuleGetter(
17   this,
18   "DeferredTask",
19   "resource://gre/modules/DeferredTask.jsm"
21 ChromeUtils.defineModuleGetter(
22   this,
23   "PromiseUtils",
24   "resource://gre/modules/PromiseUtils.jsm"
27 const STREAM_SEGMENT_SIZE = 4096;
28 const PR_UINT32_MAX = 0xffffffff;
30 const BinaryInputStream = Components.Constructor(
31   "@mozilla.org/binaryinputstream;1",
32   "nsIBinaryInputStream",
33   "setInputStream"
35 const StorageStream = Components.Constructor(
36   "@mozilla.org/storagestream;1",
37   "nsIStorageStream",
38   "init"
40 const BufferedOutputStream = Components.Constructor(
41   "@mozilla.org/network/buffered-output-stream;1",
42   "nsIBufferedOutputStream",
43   "init"
46 const SIZES_TELEMETRY_ENUM = {
47   NO_SIZES: 0,
48   ANY: 1,
49   DIMENSION: 2,
50   INVALID: 3,
53 const FAVICON_PARSING_TIMEOUT = 100;
54 const FAVICON_RICH_ICON_MIN_WIDTH = 96;
55 const PREFERRED_WIDTH = 16;
57 // URL schemes that we don't want to load and convert to data URLs.
58 const LOCAL_FAVICON_SCHEMES = ["chrome", "about", "resource", "data"];
60 const MAX_FAVICON_EXPIRATION = 7 * 24 * 60 * 60 * 1000;
61 const MAX_ICON_SIZE = 2048;
63 const TYPE_ICO = "image/x-icon";
64 const TYPE_SVG = "image/svg+xml";
66 function promiseBlobAsDataURL(blob) {
67   return new Promise((resolve, reject) => {
68     let reader = new FileReader();
69     reader.addEventListener("load", () => resolve(reader.result));
70     reader.addEventListener("error", reject);
71     reader.readAsDataURL(blob);
72   });
75 function promiseBlobAsOctets(blob) {
76   return new Promise((resolve, reject) => {
77     let reader = new FileReader();
78     reader.addEventListener("load", () => {
79       resolve(Array.from(reader.result).map(c => c.charCodeAt(0)));
80     });
81     reader.addEventListener("error", reject);
82     reader.readAsBinaryString(blob);
83   });
86 function promiseImage(stream, type) {
87   return new Promise((resolve, reject) => {
88     let imgTools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools);
90     imgTools.decodeImageAsync(
91       stream,
92       type,
93       (image, result) => {
94         if (!Components.isSuccessCode(result)) {
95           reject();
96           return;
97         }
99         resolve(image);
100       },
101       Services.tm.currentThread
102     );
103   });
106 class FaviconLoad {
107   constructor(iconInfo) {
108     this.icon = iconInfo;
110     let securityFlags;
111     if (iconInfo.node.crossOrigin === "anonymous") {
112       securityFlags = Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT;
113     } else if (iconInfo.node.crossOrigin === "use-credentials") {
114       securityFlags =
115         Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT |
116         Ci.nsILoadInfo.SEC_COOKIES_INCLUDE;
117     } else {
118       securityFlags =
119         Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT;
120     }
122     this.channel = Services.io.newChannelFromURI(
123       iconInfo.iconUri,
124       iconInfo.node,
125       iconInfo.node.nodePrincipal,
126       iconInfo.node.nodePrincipal,
127       securityFlags |
128         Ci.nsILoadInfo.SEC_ALLOW_CHROME |
129         Ci.nsILoadInfo.SEC_DISALLOW_SCRIPT,
130       Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON
131     );
133     if (this.channel instanceof Ci.nsIHttpChannel) {
134       this.channel.QueryInterface(Ci.nsIHttpChannel);
135       let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance(
136         Ci.nsIReferrerInfo
137       );
138       // Sometimes node is a document and sometimes it is an element. We need
139       // to set the referrer info correctly either way.
140       if (iconInfo.node.nodeType == iconInfo.node.DOCUMENT_NODE) {
141         referrerInfo.initWithDocument(iconInfo.node);
142       } else {
143         referrerInfo.initWithElement(iconInfo.node);
144       }
145       this.channel.referrerInfo = referrerInfo;
146     }
147     this.channel.loadFlags |=
148       Ci.nsIRequest.LOAD_BACKGROUND |
149       Ci.nsIRequest.VALIDATE_NEVER |
150       Ci.nsIRequest.LOAD_FROM_CACHE;
151     // Sometimes node is a document and sometimes it is an element. This is
152     // the easiest single way to get to the load group in both those cases.
153     this.channel.loadGroup =
154       iconInfo.node.ownerGlobal.document.documentLoadGroup;
155     this.channel.notificationCallbacks = this;
157     if (this.channel instanceof Ci.nsIHttpChannelInternal) {
158       this.channel.blockAuthPrompt = true;
159     }
161     if (
162       Services.prefs.getBoolPref("network.http.tailing.enabled", true) &&
163       this.channel instanceof Ci.nsIClassOfService
164     ) {
165       this.channel.addClassFlags(
166         Ci.nsIClassOfService.Tail | Ci.nsIClassOfService.Throttleable
167       );
168     }
169   }
171   load() {
172     this._deferred = PromiseUtils.defer();
174     // Clear the references when we succeed or fail.
175     let cleanup = () => {
176       this.channel = null;
177       this.dataBuffer = null;
178       this.stream = null;
179     };
180     this._deferred.promise.then(cleanup, cleanup);
182     this.dataBuffer = new StorageStream(STREAM_SEGMENT_SIZE, PR_UINT32_MAX);
184     // storage streams do not implement writeFrom so wrap it with a buffered stream.
185     this.stream = new BufferedOutputStream(
186       this.dataBuffer.getOutputStream(0),
187       STREAM_SEGMENT_SIZE * 2
188     );
190     try {
191       this.channel.asyncOpen(this);
192     } catch (e) {
193       this._deferred.reject(e);
194     }
196     return this._deferred.promise;
197   }
199   cancel() {
200     if (!this.channel) {
201       return;
202     }
204     this.channel.cancel(Cr.NS_BINDING_ABORTED);
205   }
207   onStartRequest(request) {}
209   onDataAvailable(request, inputStream, offset, count) {
210     this.stream.writeFrom(inputStream, count);
211   }
213   asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
214     if (oldChannel == this.channel) {
215       this.channel = newChannel;
216     }
218     callback.onRedirectVerifyCallback(Cr.NS_OK);
219   }
221   async onStopRequest(request, statusCode) {
222     if (request != this.channel) {
223       // Indicates that a redirect has occurred. We don't care about the result
224       // of the original channel.
225       return;
226     }
228     this.stream.close();
229     this.stream = null;
231     if (!Components.isSuccessCode(statusCode)) {
232       if (statusCode == Cr.NS_BINDING_ABORTED) {
233         this._deferred.reject(
234           Components.Exception(
235             `Favicon load from ${this.icon.iconUri.spec} was cancelled.`,
236             statusCode
237           )
238         );
239       } else {
240         this._deferred.reject(
241           Components.Exception(
242             `Favicon at "${this.icon.iconUri.spec}" failed to load.`,
243             statusCode
244           )
245         );
246       }
247       return;
248     }
250     if (this.channel instanceof Ci.nsIHttpChannel) {
251       if (!this.channel.requestSucceeded) {
252         this._deferred.reject(
253           Components.Exception(
254             `Favicon at "${this.icon.iconUri.spec}" failed to load: ${this.channel.responseStatusText}.`,
255             Cr.NS_ERROR_FAILURE
256           )
257         );
258         return;
259       }
260     }
262     // By default don't store icons added after "pageshow".
263     let canStoreIcon = this.icon.beforePageShow;
264     if (canStoreIcon) {
265       // Don't store icons responding with Cache-Control: no-store, but always
266       // allow root domain icons.
267       try {
268         if (
269           this.icon.iconUri.filePath != "/favicon.ico" &&
270           this.channel instanceof Ci.nsIHttpChannel &&
271           this.channel.isNoStoreResponse()
272         ) {
273           canStoreIcon = false;
274         }
275       } catch (ex) {
276         if (ex.result != Cr.NS_ERROR_NOT_AVAILABLE) {
277           throw ex;
278         }
279       }
280     }
282     // Attempt to get an expiration time from the cache.  If this fails, we'll
283     // use this default.
284     let expiration = Date.now() + MAX_FAVICON_EXPIRATION;
286     // This stuff isn't available after onStopRequest returns (so don't start
287     // any async operations before this!).
288     if (this.channel instanceof Ci.nsICacheInfoChannel) {
289       try {
290         expiration = Math.min(
291           this.channel.cacheTokenExpirationTime * 1000,
292           expiration
293         );
294       } catch (e) {
295         // Ignore failures to get the expiration time.
296       }
297     }
299     try {
300       let stream = new BinaryInputStream(this.dataBuffer.newInputStream(0));
301       let buffer = new ArrayBuffer(this.dataBuffer.length);
302       stream.readArrayBuffer(buffer.byteLength, buffer);
304       let type = this.channel.contentType;
305       let blob = new Blob([buffer], { type });
307       if (type != "image/svg+xml") {
308         let octets = await promiseBlobAsOctets(blob);
309         let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance(
310           Ci.nsIContentSniffer
311         );
312         type = sniffer.getMIMETypeFromContent(
313           this.channel,
314           octets,
315           octets.length
316         );
318         if (!type) {
319           throw Components.Exception(
320             `Favicon at "${this.icon.iconUri.spec}" did not match a known mimetype.`,
321             Cr.NS_ERROR_FAILURE
322           );
323         }
325         blob = blob.slice(0, blob.size, type);
327         let image;
328         try {
329           image = await promiseImage(this.dataBuffer.newInputStream(0), type);
330         } catch (e) {
331           throw Components.Exception(
332             `Favicon at "${this.icon.iconUri.spec}" could not be decoded.`,
333             Cr.NS_ERROR_FAILURE
334           );
335         }
337         if (image.width > MAX_ICON_SIZE || image.height > MAX_ICON_SIZE) {
338           throw Components.Exception(
339             `Favicon at "${this.icon.iconUri.spec}" is too large.`,
340             Cr.NS_ERROR_FAILURE
341           );
342         }
343       }
345       let dataURL = await promiseBlobAsDataURL(blob);
347       this._deferred.resolve({
348         expiration,
349         dataURL,
350         canStoreIcon,
351       });
352     } catch (e) {
353       this._deferred.reject(e);
354     }
355   }
357   getInterface(iid) {
358     if (iid.equals(Ci.nsIChannelEventSink)) {
359       return this;
360     }
361     throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
362   }
366  * Extract the icon width from the size attribute. It also sends the telemetry
367  * about the size type and size dimension info.
369  * @param {Array} aSizes An array of strings about size.
370  * @return {Number} A width of the icon in pixel.
371  */
372 function extractIconSize(aSizes) {
373   let width = -1;
374   let sizesType;
375   const re = /^([1-9][0-9]*)x[1-9][0-9]*$/i;
377   if (aSizes.length) {
378     for (let size of aSizes) {
379       if (size.toLowerCase() == "any") {
380         sizesType = SIZES_TELEMETRY_ENUM.ANY;
381         break;
382       } else {
383         let values = re.exec(size);
384         if (values && values.length > 1) {
385           sizesType = SIZES_TELEMETRY_ENUM.DIMENSION;
386           width = parseInt(values[1]);
387           break;
388         } else {
389           sizesType = SIZES_TELEMETRY_ENUM.INVALID;
390           break;
391         }
392       }
393     }
394   } else {
395     sizesType = SIZES_TELEMETRY_ENUM.NO_SIZES;
396   }
398   // Telemetry probes for measuring the sizes attribute
399   // usage and available dimensions.
400   Services.telemetry
401     .getHistogramById("LINK_ICON_SIZES_ATTR_USAGE")
402     .add(sizesType);
403   if (width > 0) {
404     Services.telemetry
405       .getHistogramById("LINK_ICON_SIZES_ATTR_DIMENSION")
406       .add(width);
407   }
409   return width;
413  * Get link icon URI from a link dom node.
415  * @param {DOMNode} aLink A link dom node.
416  * @return {nsIURI} A uri of the icon.
417  */
418 function getLinkIconURI(aLink) {
419   let targetDoc = aLink.ownerDocument;
420   let uri = Services.io.newURI(aLink.href, targetDoc.characterSet);
421   try {
422     uri = uri
423       .mutate()
424       .setUserPass("")
425       .finalize();
426   } catch (e) {
427     // some URIs are immutable
428   }
429   return uri;
433  * Guess a type for an icon based on its declared type or file extension.
434  */
435 function guessType(icon) {
436   // No type with no icon
437   if (!icon) {
438     return "";
439   }
441   // Use the file extension to guess at a type we're interested in
442   if (!icon.type) {
443     let extension = icon.iconUri.filePath.split(".").pop();
444     switch (extension) {
445       case "ico":
446         return TYPE_ICO;
447       case "svg":
448         return TYPE_SVG;
449     }
450   }
452   // Fuzzily prefer the type or fall back to the declared type
453   return icon.type == "image/vnd.microsoft.icon" ? TYPE_ICO : icon.type || "";
457  * Selects the best rich icon and tab icon from a list of IconInfo objects.
459  * @param {Array} iconInfos A list of IconInfo objects.
460  * @param {integer} preferredWidth The preferred width for tab icons.
461  */
462 function selectIcons(iconInfos, preferredWidth) {
463   if (!iconInfos.length) {
464     return {
465       richIcon: null,
466       tabIcon: null,
467     };
468   }
470   let preferredIcon;
471   let bestSizedIcon;
472   // Other links with the "icon" tag are the default icons
473   let defaultIcon;
474   // Rich icons are either apple-touch or fluid icons, or the ones of the
475   // dimension 96x96 or greater
476   let largestRichIcon;
478   for (let icon of iconInfos) {
479     if (!icon.isRichIcon) {
480       // First check for svg. If it's not available check for an icon with a
481       // size adapt to the current resolution. If both are not available, prefer
482       // ico files. When multiple icons are in the same set, the latest wins.
483       if (guessType(icon) == TYPE_SVG) {
484         preferredIcon = icon;
485       } else if (
486         icon.width == preferredWidth &&
487         guessType(preferredIcon) != TYPE_SVG
488       ) {
489         preferredIcon = icon;
490       } else if (
491         guessType(icon) == TYPE_ICO &&
492         (!preferredIcon || guessType(preferredIcon) == TYPE_ICO)
493       ) {
494         preferredIcon = icon;
495       }
497       // Check for an icon larger yet closest to preferredWidth, that can be
498       // downscaled efficiently.
499       if (
500         icon.width >= preferredWidth &&
501         (!bestSizedIcon || bestSizedIcon.width >= icon.width)
502       ) {
503         bestSizedIcon = icon;
504       }
505     }
507     // Note that some sites use hi-res icons without specifying them as
508     // apple-touch or fluid icons.
509     if (icon.isRichIcon || icon.width >= FAVICON_RICH_ICON_MIN_WIDTH) {
510       if (!largestRichIcon || largestRichIcon.width < icon.width) {
511         largestRichIcon = icon;
512       }
513     } else {
514       defaultIcon = icon;
515     }
516   }
518   // Now set the favicons for the page in the following order:
519   // 1. Set the best rich icon if any.
520   // 2. Set the preferred one if any, otherwise check if there's a better
521   //    sized fit.
522   // This order allows smaller icon frames to eventually override rich icon
523   // frames.
525   let tabIcon = null;
526   if (preferredIcon) {
527     tabIcon = preferredIcon;
528   } else if (bestSizedIcon) {
529     tabIcon = bestSizedIcon;
530   } else if (defaultIcon) {
531     tabIcon = defaultIcon;
532   }
534   return {
535     richIcon: largestRichIcon,
536     tabIcon,
537   };
540 class IconLoader {
541   constructor(actor) {
542     this.actor = actor;
543   }
545   async load(iconInfo) {
546     if (this._loader) {
547       this._loader.cancel();
548     }
550     if (LOCAL_FAVICON_SCHEMES.includes(iconInfo.iconUri.scheme)) {
551       // We need to do a manual security check because the channel won't do
552       // it for us.
553       try {
554         Services.scriptSecurityManager.checkLoadURIWithPrincipal(
555           iconInfo.node.nodePrincipal,
556           iconInfo.iconUri,
557           Services.scriptSecurityManager.ALLOW_CHROME
558         );
559       } catch (ex) {
560         return;
561       }
562       this.actor.sendAsyncMessage("Link:SetIcon", {
563         pageURL: iconInfo.pageUri.spec,
564         originalURL: iconInfo.iconUri.spec,
565         canUseForTab: !iconInfo.isRichIcon,
566         expiration: undefined,
567         iconURL: iconInfo.iconUri.spec,
568         canStoreIcon: true,
569       });
570       return;
571     }
573     // Let the main process that a tab icon is possibly coming.
574     this.actor.sendAsyncMessage("Link:LoadingIcon", {
575       originalURL: iconInfo.iconUri.spec,
576       canUseForTab: !iconInfo.isRichIcon,
577     });
579     try {
580       this._loader = new FaviconLoad(iconInfo);
581       let { dataURL, expiration, canStoreIcon } = await this._loader.load();
583       this.actor.sendAsyncMessage("Link:SetIcon", {
584         pageURL: iconInfo.pageUri.spec,
585         originalURL: iconInfo.iconUri.spec,
586         canUseForTab: !iconInfo.isRichIcon,
587         expiration,
588         iconURL: dataURL,
589         canStoreIcon,
590       });
591     } catch (e) {
592       if (e.result != Cr.NS_BINDING_ABORTED) {
593         Cu.reportError(e);
595         // Used mainly for tests currently.
596         this.actor.sendAsyncMessage("Link:SetFailedIcon", {
597           originalURL: iconInfo.iconUri.spec,
598           canUseForTab: !iconInfo.isRichIcon,
599         });
600       }
601     } finally {
602       this._loader = null;
603     }
604   }
606   cancel() {
607     if (!this._loader) {
608       return;
609     }
611     this._loader.cancel();
612     this._loader = null;
613   }
616 class FaviconLoader {
617   constructor(actor) {
618     this.actor = actor;
619     this.iconInfos = [];
621     // Icons added after onPageShow() are likely added by modifying <link> tags
622     // through javascript; we want to avoid storing those permanently because
623     // they are probably used to show badges, and many of them could be
624     // randomly generated. This boolean can be used to track that case.
625     this.beforePageShow = true;
627     // For every page we attempt to find a rich icon and a tab icon. These
628     // objects take care of the load process for each.
629     this.richIconLoader = new IconLoader(actor);
630     this.tabIconLoader = new IconLoader(actor);
632     this.iconTask = new DeferredTask(
633       () => this.loadIcons(),
634       FAVICON_PARSING_TIMEOUT
635     );
636   }
638   loadIcons() {
639     // If the page is unloaded immediately after the DeferredTask's timer fires
640     // we can still attempt to load icons, which will fail since the content
641     // window is no longer available. Checking if iconInfos has been cleared
642     // allows us to bail out early in this case.
643     if (!this.iconInfos.length) {
644       return;
645     }
647     let preferredWidth =
648       PREFERRED_WIDTH * Math.ceil(this.actor.contentWindow.devicePixelRatio);
649     let { richIcon, tabIcon } = selectIcons(this.iconInfos, preferredWidth);
650     this.iconInfos = [];
652     if (richIcon) {
653       this.richIconLoader.load(richIcon);
654     }
656     if (tabIcon) {
657       this.tabIconLoader.load(tabIcon);
658     }
659   }
661   addIconFromLink(aLink, aIsRichIcon) {
662     let iconInfo = makeFaviconFromLink(aLink, aIsRichIcon);
663     if (iconInfo) {
664       iconInfo.beforePageShow = this.beforePageShow;
665       this.iconInfos.push(iconInfo);
666       this.iconTask.arm();
667       return true;
668     }
669     return false;
670   }
672   addDefaultIcon(pageUri) {
673     // Currently ImageDocuments will just load the default favicon, see bug
674     // 403651 for discussion.
675     this.iconInfos.push({
676       pageUri,
677       iconUri: pageUri
678         .mutate()
679         .setPathQueryRef("/favicon.ico")
680         .finalize(),
681       width: -1,
682       isRichIcon: false,
683       type: TYPE_ICO,
684       node: this.actor.document,
685       beforePageShow: this.beforePageShow,
686     });
687     this.iconTask.arm();
688   }
690   onPageShow() {
691     // We're likely done with icon parsing so load the pending icons now.
692     if (this.iconTask.isArmed) {
693       this.iconTask.disarm();
694       this.loadIcons();
695     }
696     this.beforePageShow = false;
697   }
699   onPageHide() {
700     this.richIconLoader.cancel();
701     this.tabIconLoader.cancel();
703     this.iconTask.disarm();
704     this.iconInfos = [];
705   }
708 function makeFaviconFromLink(aLink, aIsRichIcon) {
709   let iconUri = getLinkIconURI(aLink);
710   if (!iconUri) {
711     return null;
712   }
714   // Extract the size type and width.
715   let width = extractIconSize(aLink.sizes);
717   return {
718     pageUri: aLink.ownerDocument.documentURIObject,
719     iconUri,
720     width,
721     isRichIcon: aIsRichIcon,
722     type: aLink.type,
723     node: aLink,
724   };