favicon.ts

3.4 KB · raw

1/**2 * Resolves a favicon URL for a given domain.3 *4 * On Chrome MV3 with the `favicon` permission, returns a URL backed by the5 * browser's own favicon cache (no network, no third party). On Firefox and6 * other browsers where the API is unavailable, returns null so callers can7 * render a generic fallback icon.8 *9 * Never fetches from any external URL. No domain name leaves the browser.10 */11export function faviconUrl(domain: string): string | null {12  if (!domain) return null;13  if (typeof chrome === "undefined" || !chrome.runtime?.getURL) return null;1415  try {16    const url = new URL(chrome.runtime.getURL("/_favicon/"));17    url.searchParams.set("pageUrl", `https://${domain}`);18    url.searchParams.set("size", "32");19    return url.toString();20  } catch {21    return null;22  }23}2425/**26 * Derives 2-letter initials from a domain or title string. Used as the27 * favicon fallback label when no real favicon is available.28 */29export function titleInitials(source: string): string {30  const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();31  if (!cleaned) return "·";32  const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean);33  if (words.length >= 2 && words[0]![0] && words[1]![0]) {34    return (words[0]![0]! + words[1]![0]!).toUpperCase();35  }36  const single = words[0] ?? cleaned;37  return single.slice(0, 2).toUpperCase();38}3940/**41 * Hashes a string to a deterministic HSL background colour for an initials42 * chip. Fixed saturation + lightness keeps the palette muted and dark so43 * light initials read clearly on top.44 */45export function titleBackground(source: string): string {46  let hash = 0;47  for (let i = 0; i < source.length; i++) {48    hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0;49  }50  const hue = ((hash % 360) + 360) % 360;51  return `hsl(${hue}, 32%, 26%)`;52}5354// Renders an img element to an 8×8 canvas and returns its RGB pixels as a55// compact string. Used to fingerprint what Chrome returns for "no favicon".56function imgSig(img: HTMLImageElement): string {57  const c = document.createElement("canvas");58  c.width = c.height = 8;59  const ctx = c.getContext("2d")!;60  ctx.drawImage(img, 0, 0, 8, 8);61  const d = ctx.getImageData(0, 0, 8, 8).data;62  let s = "";63  for (let i = 0; i < d.length; i += 4) s += `${d[i]},${d[i + 1]},${d[i + 2]};`;64  return s;65}6667// Cached promise so the globe reference image is only loaded once per page.68let _globeSig: Promise<string | null> | undefined;6970/**71 * Returns the pixel signature of what Chrome's _favicon/ endpoint returns72 * when it has no stored favicon for a domain (the default globe icon).73 * Cached after the first call. Returns null when the favicon API is74 * unavailable (Firefox) or the canvas read is blocked.75 */76export function globeFaviconSig(): Promise<string | null> {77  if (_globeSig) return _globeSig;78  _globeSig = new Promise<string | null>((resolve) => {79    const src = faviconUrl("zz-peach-no-favicon-sentinel.nonexistent");80    if (!src) { resolve(null); return; }81    const img = new Image();82    img.onload = () => { try { resolve(imgSig(img)); } catch { resolve(null); } };83    img.onerror = () => resolve(null);84    img.src = src;85  });86  return _globeSig;87}8889/**90 * Returns the 8×8 canvas pixel signature for a loaded img element.91 * Returns null if the canvas read is blocked (e.g. security error).92 */93export function faviconImgSig(img: HTMLImageElement): string | null {94  try { return imgSig(img); } catch { return null; }95}