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