/** * Resolves a favicon URL for a given domain. * * On Chrome MV3 with the `favicon` permission, returns a URL backed by the * browser's own favicon cache (no network, no third party). On Firefox and * other browsers where the API is unavailable, returns null so callers can * render a generic fallback icon. * * Never fetches from any external URL. No domain name leaves the browser. */ export function faviconUrl(domain: string): string | null { if (!domain) return null; if (typeof chrome === "undefined" || !chrome.runtime?.getURL) return null; try { const url = new URL(chrome.runtime.getURL("/_favicon/")); url.searchParams.set("pageUrl", `https://${domain}`); url.searchParams.set("size", "32"); return url.toString(); } catch { return null; } } /** * Derives 2-letter initials from a domain or title string. Used as the * favicon fallback label when no real favicon is available. */ export function titleInitials(source: string): string { const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim(); if (!cleaned) return "·"; const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean); if (words.length >= 2 && words[0]![0] && words[1]![0]) { return (words[0]![0]! + words[1]![0]!).toUpperCase(); } const single = words[0] ?? cleaned; return single.slice(0, 2).toUpperCase(); } /** * Hashes a string to a deterministic HSL background colour for an initials * chip. Fixed saturation + lightness keeps the palette muted and dark so * light initials read clearly on top. */ export function titleBackground(source: string): string { let hash = 0; for (let i = 0; i < source.length; i++) { hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0; } const hue = ((hash % 360) + 360) % 360; return `hsl(${hue}, 32%, 26%)`; } // Renders an img element to an 8×8 canvas and returns its RGB pixels as a // compact string. Used to fingerprint what Chrome returns for "no favicon". function imgSig(img: HTMLImageElement): string { const c = document.createElement("canvas"); c.width = c.height = 8; const ctx = c.getContext("2d")!; ctx.drawImage(img, 0, 0, 8, 8); const d = ctx.getImageData(0, 0, 8, 8).data; let s = ""; for (let i = 0; i < d.length; i += 4) s += `${d[i]},${d[i + 1]},${d[i + 2]};`; return s; } // Cached promise so the globe reference image is only loaded once per page. let _globeSig: Promise | undefined; /** * Returns the pixel signature of what Chrome's _favicon/ endpoint returns * when it has no stored favicon for a domain (the default globe icon). * Cached after the first call. Returns null when the favicon API is * unavailable (Firefox) or the canvas read is blocked. */ export function globeFaviconSig(): Promise { if (_globeSig) return _globeSig; _globeSig = new Promise((resolve) => { const src = faviconUrl("zz-peach-no-favicon-sentinel.nonexistent"); if (!src) { resolve(null); return; } const img = new Image(); img.onload = () => { try { resolve(imgSig(img)); } catch { resolve(null); } }; img.onerror = () => resolve(null); img.src = src; }); return _globeSig; } /** * Returns the 8×8 canvas pixel signature for a loaded img element. * Returns null if the canvas read is blocked (e.g. security error). */ export function faviconImgSig(img: HTMLImageElement): string | null { try { return imgSig(img); } catch { return null; } }