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